From 9de747d13524456e1e2870cfda9b893f5afaa683 Mon Sep 17 00:00:00 2001 From: alex s <46074070+bearsyankees@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:28:34 -0400 Subject: [PATCH 01/57] =?UTF-8?q?fix(cost):=20capture=20OpenRouter=20strea?= =?UTF-8?q?med=20usage.cost=20(fixes=20$0=20kimi-k3=20c=E2=80=A6=20(#929)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cost): capture OpenRouter streamed usage.cost (fixes $0 kimi-k3 cost) * refactor(cost): encapsulate streamed OpenRouter cost cache, clear per run * test(cost): resolve OpenRouter handler via LiteLLM provider pipeline --- strix/config/models.py | 45 +++++++++++++++ strix/report/state.py | 74 +++++++++++++++++++++++++ tests/test_cost_tracking.py | 107 +++++++++++++++++++++++++++++++++++- 3 files changed, 224 insertions(+), 2 deletions(-) diff --git a/strix/config/models.py b/strix/config/models.py index b6e6b0f0..1401dc5a 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -277,6 +277,51 @@ def _configure_litellm_compatibility() -> None: litellm.suppress_debug_info = True _register_litellm_cost_callback() + _install_openrouter_stream_cost_capture() + + +def _install_openrouter_stream_cost_capture() -> None: + """Preserve OpenRouter's per-stream cost, which LiteLLM drops when streaming. + + OpenRouter reports the real charge in ``usage.cost`` of the final stream + chunk, but LiteLLM rebuilds streamed responses from token-only fields and + discards it (its non-streamed path stashes the cost in hidden params; the + streaming path does not). Every scan streams, so without this the cost is + lost and Strix falls back to a cost-map estimate that is missing entirely + for new models (e.g. kimi-k3), reporting $0. Subclass the OpenRouter + streaming handler to record the cost keyed by response id so the cost + callback can recover the exact charge for the matching rebuilt response. + """ + import litellm + from litellm.llms.openrouter.chat.transformation import ( + OpenRouterChatCompletionStreamingHandler, + OpenrouterConfig, + ) + + from strix.report.state import streamed_openrouter_costs + + class _StrixOpenRouterStreamingHandler(OpenRouterChatCompletionStreamingHandler): + def chunk_parser(self, chunk: dict[str, Any]) -> Any: + stream = super().chunk_parser(chunk) + streamed_openrouter_costs.remember( + chunk.get("id") or getattr(stream, "id", None), chunk.get("usage") + ) + return stream + + class _StrixOpenrouterConfig(OpenrouterConfig): + def get_model_response_iterator( + self, streaming_response: Any, sync_stream: bool, json_mode: bool | None = False + ) -> Any: + return _StrixOpenRouterStreamingHandler( + streaming_response=streaming_response, + sync_stream=sync_stream, + json_mode=json_mode, + ) + + # LiteLLM's provider-config factory reads litellm.OpenrouterConfig at call + # time, so overriding the attribute is enough for the subclass to take + # effect. (type: ignore — mypy rejects reassigning a class attribute.) + litellm.OpenrouterConfig = _StrixOpenrouterConfig # type: ignore[misc] _OPENROUTER_ATTRIBUTION_HEADERS = { diff --git a/strix/report/state.py b/strix/report/state.py index 1475ccf3..490afa96 100644 --- a/strix/report/state.py +++ b/strix/report/state.py @@ -1,6 +1,7 @@ import json import logging import subprocess +import threading from collections.abc import Callable from datetime import UTC, datetime from importlib.metadata import PackageNotFoundError, version @@ -95,6 +96,8 @@ def get_global_report_state() -> Optional["ReportState"]: def set_global_report_state(report_state: "ReportState") -> None: global _global_report_state # noqa: PLW0603 _global_report_state = report_state + # New run: drop any streamed-cost entries a prior run left unconsumed. + streamed_openrouter_costs.clear() class ReportState: @@ -507,6 +510,72 @@ class ReportState: self._sync_llm_usage_record() +def openrouter_stream_cost(usage: Any) -> float | None: + """Total OpenRouter-reported cost from a raw stream ``usage`` block, or None. + + Non-BYOK responses bill everything to ``usage.cost``. BYOK responses put the + OpenRouter fee in ``usage.cost`` (often 0) and the provider charge in + ``usage.cost_details.upstream_inference_cost``, so BYOK totals sum the two. + """ + if not isinstance(usage, dict): + return None + total = 0.0 + cost = usage.get("cost") + if isinstance(cost, int | float) and cost > 0: + total += float(cost) + if bool(usage.get("is_byok")): + details = usage.get("cost_details") + upstream = details.get("upstream_inference_cost") if isinstance(details, dict) else None + if isinstance(upstream, int | float) and upstream > 0: + total += float(upstream) + return total if total > 0 else None + + +def _response_id(completion_response: Any) -> str | None: + response_id = getattr(completion_response, "id", None) + if response_id is None and isinstance(completion_response, dict): + response_id = cast("dict[str, Any]", completion_response).get("id") + return response_id if isinstance(response_id, str) and response_id else None + + +class StreamedOpenRouterCosts: + """Correlates OpenRouter's per-stream cost from the parser to the cost callback. + + LiteLLM rebuilds streamed responses from token-only chunks and drops the + ``usage.cost`` OpenRouter reports in its final stream chunk (its non-streamed + path preserves it; streaming snapshots hidden params at stream start). Every + scan streams, so the OpenRouter streaming handler (see strix.config.models) + records the cost here keyed by response id, and the callback takes it back out + for the matching rebuilt response. Entries are removed on read; ``clear()`` + runs per scan so nothing accumulates across runs. + """ + + def __init__(self) -> None: + self._costs: dict[str, float] = {} + self._lock = threading.Lock() + + def remember(self, response_id: Any, usage: Any) -> None: + cost = openrouter_stream_cost(usage) + if cost is None or not (isinstance(response_id, str) and response_id): + return + with self._lock: + self._costs[response_id] = cost + + def take(self, completion_response: Any) -> float | None: + response_id = _response_id(completion_response) + if response_id is None: + return None + with self._lock: + return self._costs.pop(response_id, None) + + def clear(self) -> None: + with self._lock: + self._costs.clear() + + +streamed_openrouter_costs = StreamedOpenRouterCosts() + + def litellm_cost_callback( kwargs: Any, completion_response: Any, @@ -541,6 +610,11 @@ def litellm_cost_callback( if cost is None: cost = _usage_reported_cost(completion_response) + # Recover the exact OpenRouter cost the streaming handler stashed for this + # response — LiteLLM drops it from streamed usage, so nothing above sees it. + if cost is None: + cost = streamed_openrouter_costs.take(completion_response) + if cost is None: cost = _estimate_response_cost(kwargs, completion_response) diff --git a/tests/test_cost_tracking.py b/tests/test_cost_tracking.py index 543d3fd5..065b7cb8 100644 --- a/tests/test_cost_tracking.py +++ b/tests/test_cost_tracking.py @@ -7,9 +7,25 @@ from unittest.mock import MagicMock, patch import litellm import pytest +from litellm.types.utils import LlmProviders +from litellm.utils import ProviderConfigManager -from strix.config.models import _configure_litellm_compatibility -from strix.report.state import litellm_cost_callback +from strix.config.models import ( + _configure_litellm_compatibility, + _install_openrouter_stream_cost_capture, +) +from strix.report.state import ( + ReportState, + litellm_cost_callback, + openrouter_stream_cost, + set_global_report_state, + streamed_openrouter_costs, +) + + +@pytest.fixture(autouse=True) +def _clear_streamed_costs() -> None: + streamed_openrouter_costs.clear() def test_streaming_logging_stays_enabled_for_cost_callback() -> None: @@ -151,3 +167,90 @@ def test_cost_callback_records_nothing_when_no_cost_available() -> None: litellm_cost_callback({"response_cost": None, "model": "x/y"}, response) report_state.record_observed_llm_cost.assert_not_called() + + +def test_openrouter_stream_cost_extracts_plain_and_byok_totals() -> None: + assert openrouter_stream_cost({"cost": 0.003168}) == pytest.approx(0.003168) + assert openrouter_stream_cost( + {"cost": 0.01, "is_byok": True, "cost_details": {"upstream_inference_cost": 0.2}} + ) == pytest.approx(0.21) + # Upstream cost is only added for BYOK responses. + assert openrouter_stream_cost( + {"cost": 0.05, "is_byok": False, "cost_details": {"upstream_inference_cost": 0.04}} + ) == pytest.approx(0.05) + assert openrouter_stream_cost({"prompt_tokens": 10}) is None + assert openrouter_stream_cost(None) is None + + +def test_cost_callback_recovers_streamed_openrouter_cost_by_response_id() -> None: + report_state = MagicMock() + streamed_openrouter_costs.remember("gen-abc", {"cost": 0.42}) + # LiteLLM strips cost from the rebuilt streamed usage; only the id survives. + response = SimpleNamespace(id="gen-abc", usage=SimpleNamespace(cost=None), _hidden_params={}) + + with ( + patch("strix.report.state.get_global_report_state", return_value=report_state), + patch("litellm.completion_cost", side_effect=ValueError("unknown model")), + ): + litellm_cost_callback({"response_cost": None, "model": "moonshotai/kimi-k3"}, response) + + report_state.record_observed_llm_cost.assert_called_once_with(0.42) + # The entry is consumed so a later response cannot double-count it. + assert streamed_openrouter_costs.take(response) is None + + +def test_streamed_openrouter_cost_prefers_provider_report_over_estimate() -> None: + report_state = MagicMock() + streamed_openrouter_costs.remember("gen-xyz", {"cost": 0.9}) + response = SimpleNamespace( + id="gen-xyz", + usage=SimpleNamespace(prompt_tokens=10, completion_tokens=5, total_tokens=15), + _hidden_params={}, + ) + + with ( + patch("strix.report.state.get_global_report_state", return_value=report_state), + patch("litellm.completion_cost", return_value=0.1) as estimate, + ): + litellm_cost_callback({"response_cost": None, "model": "moonshotai/kimi-k3"}, response) + + report_state.record_observed_llm_cost.assert_called_once_with(0.9) + estimate.assert_not_called() + + +def test_streamed_openrouter_costs_ignores_entries_without_cost() -> None: + streamed_openrouter_costs.remember("gen-none", {"prompt_tokens": 10}) + streamed_openrouter_costs.remember("", {"cost": 0.5}) + assert streamed_openrouter_costs.take(SimpleNamespace(id="gen-none")) is None + + +def test_streamed_openrouter_costs_cleared_on_new_run() -> None: + streamed_openrouter_costs.remember("gen-stale", {"cost": 0.7}) + set_global_report_state(ReportState.__new__(ReportState)) + assert streamed_openrouter_costs.take(SimpleNamespace(id="gen-stale")) is None + + +def test_openrouter_stream_handler_records_cost() -> None: + _install_openrouter_stream_cost_capture() + # Resolve the config the way LiteLLM does in production so we prove the + # override is actually reachable through provider resolution, not just as a + # directly-constructed class. + config = ProviderConfigManager.get_provider_chat_config( + model="moonshotai/kimi-k3", provider=LlmProviders.OPENROUTER + ) + assert config is not None + assert type(config).__name__ == "_StrixOpenrouterConfig" + handler = config.get_model_response_iterator(streaming_response=iter([]), sync_stream=True) + + chunk = { + "id": "gen-stream", + "created": 1, + "model": "moonshotai/kimi-k3", + "choices": [{"index": 0, "delta": {"content": None}}], + "usage": {"prompt_tokens": 89, "completion_tokens": 138, "cost": 0.0035055}, + } + handler.chunk_parser(chunk) + + assert streamed_openrouter_costs.take(SimpleNamespace(id="gen-stream")) == pytest.approx( + 0.0035055 + ) From 1a2fa89972388f8b057c505ecb1c7dc3d1bdf702 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:05:42 -0700 Subject: [PATCH 02/57] fix(runtime): label docker sandbox containers with the run id for teardown (#933) Co-authored-by: Ahmed Allam --- strix/runtime/docker_client.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/strix/runtime/docker_client.py b/strix/runtime/docker_client.py index 11cdff57..5aa4f7c7 100644 --- a/strix/runtime/docker_client.py +++ b/strix/runtime/docker_client.py @@ -110,6 +110,19 @@ def _apply_log_limits(create_kwargs: dict[str, Any]) -> None: ) +def _apply_run_labels(create_kwargs: dict[str, Any]) -> None: + run_id = os.getenv("STRIX_RUN_ID") + if not run_id: + return + labels = create_kwargs.setdefault("labels", {}) + if not isinstance(labels, dict): + return + labels["strix-run-id"] = run_id + run_type = os.getenv("STRIX_RUN_TYPE") + if run_type: + labels["strix-run-type"] = run_type + + class StrixDockerSandboxSession(DockerSandboxSession): sandbox_network: str = "" @@ -222,6 +235,7 @@ class StrixDockerSandboxClient(DockerSandboxClient): _apply_sandbox_network(create_kwargs) _apply_resource_limits(create_kwargs) _apply_log_limits(create_kwargs) + _apply_run_labels(create_kwargs) # Strix injection: host bind mounts (e.g. large repos passed via --mount) # that bypass the SDK's file-by-file LocalDir copy. From ebb3a62a99526233411fb416500462fed3ecefa5 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 30 Jul 2026 01:02:45 +0000 Subject: [PATCH 03/57] feat(llm): custom request headers for OpenAI-compatible endpoints via LLM_EXTRA_HEADERS --- docs/advanced/configuration.mdx | 8 ++++ docs/llm-providers/local.mdx | 17 +++++++ strix/config/models.py | 41 +++++++++++++++- strix/config/settings.py | 4 ++ tests/test_llm_extra_headers.py | 83 +++++++++++++++++++++++++++++++++ 5 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 tests/test_llm_extra_headers.py diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index fe911907..b446fb67 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -19,6 +19,14 @@ Configure Strix using environment variables or a config file. Custom API base URL. Also accepts `OPENAI_API_BASE`, `LITELLM_BASE_URL`, or `OLLAMA_API_BASE`. + + Extra HTTP headers sent on every LLM request, as a JSON object (e.g. + `{"X-Feature-Key":"value","X-Tenant":"acme"}`). Useful for OpenAI-compatible + gateways that require attribution or routing headers in addition to the bearer + token. The bearer token itself still comes from `LLM_API_KEY`. Applies to both + the LiteLLM and native OpenAI routing paths. + + Request timeout in seconds for LLM calls. diff --git a/docs/llm-providers/local.mdx b/docs/llm-providers/local.mdx index 8a899a5d..212dd1b7 100644 --- a/docs/llm-providers/local.mdx +++ b/docs/llm-providers/local.mdx @@ -54,3 +54,20 @@ If you use LM Studio, vLLM, or other runners: export STRIX_LLM="openai/local-model" export LLM_API_BASE="http://localhost:1234/v1" # Adjust port as needed ``` + +### Gateways that require custom headers + +Some OpenAI-compatible gateways require extra HTTP headers (for attribution or +tenant routing) alongside the bearer token. Set them with `LLM_EXTRA_HEADERS` as +a JSON object — they are sent on every request: + +```bash +export STRIX_LLM="openai/your-model" +export LLM_API_BASE="https://your-gateway.example/v1" +export LLM_API_KEY="your-bearer-token" # sent as Authorization: Bearer ... +export LLM_EXTRA_HEADERS='{"X-Feature-Key":"value","X-Tenant":"acme"}' +``` + +For endpoints behind a private CA, point Strix at your certificate bundle with +the standard `SSL_CERT_FILE=/path/to/ca-bundle.pem` — never disable TLS +verification against a real endpoint. diff --git a/strix/config/models.py b/strix/config/models.py index 1401dc5a..34e298ca 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -33,7 +33,7 @@ if TYPE_CHECKING: from agents.models.interface import Model, ModelProvider from openai import AsyncOpenAI - from strix.config.settings import ReasoningEffort, Settings + from strix.config.settings import LlmSettings, ReasoningEffort, Settings def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None: @@ -243,6 +243,7 @@ def configure_sdk_model_defaults(settings: Settings) -> None: set_default_openai_api("chat_completions") else: set_default_openai_api("responses") + _configure_extra_headers(llm) def _mirror_api_key_to_provider_env(model_name: str | None, api_key: str) -> None: @@ -347,6 +348,44 @@ def _configure_openrouter_attribution(model_name: str | None) -> None: litellm.headers = {**existing, **_OPENROUTER_ATTRIBUTION_HEADERS} # type: ignore[assignment] +def _configure_extra_headers(llm: LlmSettings) -> None: + """Send user-provided default headers on every LLM request. + + Some OpenAI-compatible endpoints require extra HTTP headers (e.g. request + attribution or tenant routing) alongside the bearer token. Users supply + them via ``LLM_EXTRA_HEADERS``; they are applied to both routing paths: + the LiteLLM route (``litellm.headers``) and the SDK-native OpenAI route + (a default client carrying ``default_headers``), so they take effect + regardless of the ``STRIX_LLM`` prefix. + """ + headers = llm.extra_headers + if not headers: + return + _merge_litellm_headers(headers) + if llm.api_base: + _register_openai_client_with_headers(llm, headers) + + +def _merge_litellm_headers(headers: dict[str, str]) -> None: + import litellm + + current: object = litellm.headers + existing: dict[str, str] = current if isinstance(current, dict) else {} + litellm.headers = {**existing, **headers} # type: ignore[assignment] + + +def _register_openai_client_with_headers(llm: LlmSettings, headers: dict[str, str]) -> None: + from agents import set_default_openai_client + from openai import AsyncOpenAI + + client = AsyncOpenAI( + api_key=llm.api_key or "not-needed", + base_url=llm.api_base, + default_headers=dict(headers), + ) + set_default_openai_client(client, use_for_tracing=False) + + def _register_litellm_cost_callback() -> None: import litellm diff --git a/strix/config/settings.py b/strix/config/settings.py index 78d52273..31f479b0 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -35,6 +35,10 @@ class LlmSettings(BaseSettings): "OLLAMA_API_BASE", ), ) + extra_headers: dict[str, str] | None = Field( + default=None, + alias="LLM_EXTRA_HEADERS", + ) reasoning_effort: ReasoningEffort = Field(default="high", alias="STRIX_REASONING_EFFORT") force_required_tool_choice: bool = Field( default=False, diff --git a/tests/test_llm_extra_headers.py b/tests/test_llm_extra_headers.py new file mode 100644 index 00000000..e65b72aa --- /dev/null +++ b/tests/test_llm_extra_headers.py @@ -0,0 +1,83 @@ +"""Tests for LLM_EXTRA_HEADERS: custom default headers on OpenAI-compatible endpoints.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import litellm +import pytest +from agents.models import _openai_shared + +from strix.config import loader +from strix.config.loader import load_settings +from strix.config.models import configure_sdk_model_defaults + + +if TYPE_CHECKING: + from collections.abc import Iterator + + +_ENV_KEYS = ["STRIX_LLM", "LLM_API_KEY", "LLM_API_BASE", "LLM_EXTRA_HEADERS"] + + +@pytest.fixture(autouse=True) +def _reset(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + for key in _ENV_KEYS: + monkeypatch.delenv(key, raising=False) + monkeypatch.setattr(loader, "_cached", None) + monkeypatch.setattr(loader, "_override", None) + + saved_headers = litellm.headers + saved_client = _openai_shared.get_default_openai_client() + litellm.headers = None + try: + yield + finally: + litellm.headers = saved_headers + _openai_shared.set_default_openai_client(saved_client) # type: ignore[arg-type] + + +def test_extra_headers_parsed_from_json_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LLM_EXTRA_HEADERS", json.dumps({"X-A": "1", "X-B": "2"})) + settings = load_settings() + assert settings.llm.extra_headers == {"X-A": "1", "X-B": "2"} + + +def test_extra_headers_merged_into_litellm_headers(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIX_LLM", "litellm/openai/some-model") + monkeypatch.setenv("LLM_API_BASE", "https://gateway.example/v1") + monkeypatch.setenv("LLM_API_KEY", "token") + headers = {"X-Feature-Key": "svc", "X-Tenant": "acme"} + monkeypatch.setenv("LLM_EXTRA_HEADERS", json.dumps(headers)) + + configure_sdk_model_defaults(load_settings()) + + current: object = litellm.headers + assert isinstance(current, dict) + assert current["X-Feature-Key"] == "svc" + assert current["X-Tenant"] == "acme" + + +def test_extra_headers_applied_to_native_openai_client(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIX_LLM", "openai/some-model") + monkeypatch.setenv("LLM_API_BASE", "https://gateway.example/v1") + monkeypatch.setenv("LLM_API_KEY", "token") + monkeypatch.setenv("LLM_EXTRA_HEADERS", json.dumps({"X-Feature-Key": "svc"})) + + configure_sdk_model_defaults(load_settings()) + + client = _openai_shared.get_default_openai_client() + assert client is not None + assert client.default_headers.get("X-Feature-Key") == "svc" + assert str(client.base_url).rstrip("/") == "https://gateway.example/v1" + + +def test_no_extra_headers_leaves_litellm_headers_untouched(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIX_LLM", "openai/some-model") + monkeypatch.setenv("LLM_API_BASE", "https://gateway.example/v1") + monkeypatch.setenv("LLM_API_KEY", "token") + + configure_sdk_model_defaults(load_settings()) + + assert litellm.headers is None From e9ebdc502f05b76026661818d17326fce0bab029 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 30 Jul 2026 01:08:32 +0000 Subject: [PATCH 04/57] fix(llm): apply LLM_EXTRA_HEADERS on native OpenAI route even without a custom base --- strix/config/models.py | 3 +-- tests/test_llm_extra_headers.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/strix/config/models.py b/strix/config/models.py index 34e298ca..7892957d 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -362,8 +362,7 @@ def _configure_extra_headers(llm: LlmSettings) -> None: if not headers: return _merge_litellm_headers(headers) - if llm.api_base: - _register_openai_client_with_headers(llm, headers) + _register_openai_client_with_headers(llm, headers) def _merge_litellm_headers(headers: dict[str, str]) -> None: diff --git a/tests/test_llm_extra_headers.py b/tests/test_llm_extra_headers.py index e65b72aa..f2910156 100644 --- a/tests/test_llm_extra_headers.py +++ b/tests/test_llm_extra_headers.py @@ -73,6 +73,20 @@ def test_extra_headers_applied_to_native_openai_client(monkeypatch: pytest.Monke assert str(client.base_url).rstrip("/") == "https://gateway.example/v1" +def test_extra_headers_applied_to_native_openai_without_custom_base( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("STRIX_LLM", "openai/gpt-5") + monkeypatch.setenv("LLM_API_KEY", "token") + monkeypatch.setenv("LLM_EXTRA_HEADERS", json.dumps({"X-Feature-Key": "svc"})) + + configure_sdk_model_defaults(load_settings()) + + client = _openai_shared.get_default_openai_client() + assert client is not None + assert client.default_headers.get("X-Feature-Key") == "svc" + + def test_no_extra_headers_leaves_litellm_headers_untouched(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("STRIX_LLM", "openai/some-model") monkeypatch.setenv("LLM_API_BASE", "https://gateway.example/v1") From d4e58b2cd0753870276677cfc182905f04ad6570 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:38:06 -0700 Subject: [PATCH 05/57] fix(llm): pass LLM_EXTRA_HEADERS through ModelSettings so they reach the agent loop (#937) --- docs/advanced/configuration.mdx | 6 ++ strix/config/settings.py | 4 ++ strix/core/inputs.py | 2 + strix/core/runner.py | 1 + strix/interface/main.py | 24 ++++++- strix/interface/tui/app.py | 3 +- strix/llm/compaction.py | 56 ++++++++++++---- strix/report/dedupe.py | 11 ++- tests/test_compaction.py | 111 +++++++++++++++++++------------ tests/test_dedupe_model.py | 32 +++++++++ tests/test_inputs.py | 24 +++++++ tests/test_runner_rate_limit.py | 1 + tests/test_runner_root_prompt.py | 1 + 13 files changed, 215 insertions(+), 61 deletions(-) diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index b446fb67..af98b8b8 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -63,6 +63,12 @@ affecting the agents that do the actual testing. model runs on a different endpoint than the main model. + + Optional JSON object of extra HTTP headers sent on every deduplication-model + request, e.g. `{"X-Feature-Key":"value"}`. A dedicated dedupe model never + inherits `LLM_EXTRA_HEADERS`; set this when its endpoint needs custom headers. + + Reasoning effort for the deduplication model. Defaults to the model's own baseline when unset. diff --git a/strix/config/settings.py b/strix/config/settings.py index 31f479b0..016a8ad9 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -61,6 +61,10 @@ class DedupeSettings(BaseSettings): ) api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY") api_base: str | None = Field(default=None, alias="DEDUPE_LLM_API_BASE") + extra_headers: dict[str, str] | None = Field( + default=None, + alias="DEDUPE_LLM_EXTRA_HEADERS", + ) class ContextSettings(BaseSettings): diff --git a/strix/core/inputs.py b/strix/core/inputs.py index aef1fe13..34a2d4b3 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -132,12 +132,14 @@ def make_model_settings( force_required_tool_choice: bool = False, request_timeout: float | None = None, prompt_cache: bool = True, + extra_headers: dict[str, str] | None = None, ) -> ModelSettings: model_settings = ModelSettings( parallel_tool_calls=False, retry=DEFAULT_MODEL_RETRY, include_usage=True, extra_args=request_timeout_extra_args(request_timeout), + extra_headers=dict(extra_headers) if extra_headers else None, ) if ( reasoning_effort is not None diff --git a/strix/core/runner.py b/strix/core/runner.py index c5f51b15..01725cab 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -250,6 +250,7 @@ async def run_strix_scan( force_required_tool_choice=settings.llm.force_required_tool_choice, request_timeout=settings.llm.timeout, prompt_cache=settings.llm.prompt_cache, + extra_headers=settings.llm.extra_headers, ) run_config = RunConfig( model=resolved_model, diff --git a/strix/interface/main.py b/strix/interface/main.py index 0fbcbf48..4d88beda 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -31,7 +31,7 @@ from strix.config.models import ( is_known_openai_bare_model, is_recommended_or_frontier_model, ) -from strix.core.inputs import DEFAULT_MAX_TURNS +from strix.core.inputs import DEFAULT_MAX_TURNS, make_model_settings from strix.core.paths import run_dir_for, runtime_state_dir from strix.interface.cli import run_cli from strix.interface.tui import run_tui @@ -382,7 +382,13 @@ async def warm_up_llm(show_model_warning: bool = True) -> None: model.get_response( system_instructions="You are a helpful assistant.", input="Reply with just 'OK'.", - model_settings=ModelSettings(), + model_settings=make_model_settings( + None, + model_name=raw_model, + request_timeout=llm.timeout, + prompt_cache=False, + extra_headers=llm.extra_headers, + ), tools=[], output_schema=None, handoffs=[], @@ -404,7 +410,19 @@ async def warm_up_llm(show_model_warning: bool = True) -> None: # Match the runtime path: send the dedupe key/endpoint per call so a # separate-provider dedupe model authenticates during warm-up too. deduper_extra = _dedupe_extra_args(settings.dedupe) - deduper_settings = ModelSettings(extra_args=deduper_extra or None) + # A dedicated dedupe model may route to another provider, which must + # never receive the main endpoint's headers; it has its own + # DEDUPE_LLM_EXTRA_HEADERS. + deduper_settings = make_model_settings( + None, + model_name=dedupe_model, + request_timeout=llm.timeout, + prompt_cache=False, + extra_headers=settings.dedupe.extra_headers, + ) + if deduper_extra: + merged = {**(deduper_settings.extra_args or {}), **deduper_extra} + deduper_settings = deduper_settings.resolve(ModelSettings(extra_args=merged)) await asyncio.wait_for( deduper.get_response( system_instructions="You are a helpful assistant.", diff --git a/strix/interface/tui/app.py b/strix/interface/tui/app.py index cc5a36c4..14bc6cb1 100644 --- a/strix/interface/tui/app.py +++ b/strix/interface/tui/app.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Any, ClassVar if TYPE_CHECKING: + from pygments.token import _TokenType from textual.timer import Timer from rich.align import Align @@ -352,7 +353,7 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc] if not token_value: continue color = None - tt = token_type + tt: _TokenType | None = token_type while tt: if tt in colors: color = colors[tt] diff --git a/strix/llm/compaction.py b/strix/llm/compaction.py index da612ff2..09d36454 100644 --- a/strix/llm/compaction.py +++ b/strix/llm/compaction.py @@ -12,15 +12,20 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING, Any -import litellm +from agents.model_settings import ModelSettings +from agents.models.interface import ModelTracing from litellm.exceptions import BadRequestError, ContextWindowExceededError +from openai.types.responses import ResponseOutputMessage, ResponseOutputText from strix.config import load_settings +from strix.config.models import StrixProvider +from strix.core.inputs import make_model_settings from strix.core.sessions import replace_session_items, session_write_lock from strix.llm.context_budget import context_window, count_tokens, output_limit if TYPE_CHECKING: + from agents.items import ModelResponse from agents.memory import Session @@ -268,26 +273,53 @@ def _checkpoint_item(summary: str) -> dict[str, Any]: } +def _extract_text(response: ModelResponse) -> str: + parts: list[str] = [] + for item in response.output: + if not isinstance(item, ResponseOutputMessage): + continue + parts.extend( + chunk.text + for chunk in item.content + if isinstance(chunk, ResponseOutputText) and chunk.text + ) + return "".join(parts) + + async def _summarize(model: str, prompt: str, max_tokens: int) -> str | None: llm = load_settings().llm + model_settings = make_model_settings( + None, + model_name=model, + request_timeout=llm.timeout, + prompt_cache=False, + extra_headers=llm.extra_headers, + ).resolve(ModelSettings(max_tokens=max_tokens)) try: - response = await litellm.acompletion( - model=model, - messages=[{"role": "user", "content": prompt}], - max_tokens=max_tokens, - api_key=llm.api_key, - api_base=llm.api_base, - timeout=llm.timeout, + response = ( + await StrixProvider() + .get_model(model) + .get_response( + system_instructions=None, + input=prompt, + model_settings=model_settings, + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) ) except Exception: logger.exception("compaction summary call failed for model %s", model) return None - try: - content = response.choices[0].message.content - except (AttributeError, IndexError, KeyError): + content = _extract_text(response).strip() + if not content: logger.warning("compaction summary returned no content") return None - return content.strip() if isinstance(content, str) and content.strip() else None + return content async def maybe_compact( diff --git a/strix/report/dedupe.py b/strix/report/dedupe.py index b47ed5a4..23db066f 100644 --- a/strix/report/dedupe.py +++ b/strix/report/dedupe.py @@ -51,17 +51,24 @@ def _dedupe_extra_args(dedupe: DedupeSettings) -> dict[str, str]: def _dedupe_model_settings( dedupe: DedupeSettings, model_name: str, request_timeout: float | None ) -> ModelSettings: + llm = load_settings().llm settings = make_model_settings( dedupe.reasoning_effort, model_name=model_name, force_required_tool_choice=False, request_timeout=request_timeout, + # The main model's headers apply only when dedupe falls back to the main + # model; a dedicated dedupe model may route to another provider, which + # must never receive the main endpoint's credentials. A dedicated model + # gets its own DEDUPE_LLM_EXTRA_HEADERS instead. + extra_headers=dedupe.extra_headers if dedupe.model else llm.extra_headers, ) extra = _dedupe_extra_args(dedupe) if extra: settings = settings.resolve(ModelSettings(extra_args=extra)) return settings + DEDUPE_SYSTEM_PROMPT = """You are an expert vulnerability report deduplication judge. Your task is to determine if a candidate vulnerability report describes the SAME vulnerability as any existing report. @@ -347,9 +354,7 @@ async def check_duplicate( response = await model.get_response( system_instructions=DEDUPE_SYSTEM_PROMPT, input=user_msg, - model_settings=_dedupe_model_settings( - dedupe, resolved_model, settings.llm.timeout - ), + model_settings=_dedupe_model_settings(dedupe, resolved_model, settings.llm.timeout), tools=[], output_schema=None, handoffs=[], diff --git a/tests/test_compaction.py b/tests/test_compaction.py index da9a2361..2d73d9ac 100644 --- a/tests/test_compaction.py +++ b/tests/test_compaction.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any import pytest from litellm.exceptions import BadRequestError, ContextWindowExceededError, RateLimitError +from openai.types.responses import ResponseOutputMessage, ResponseOutputText from strix.config import ContextSettings from strix.llm import compaction @@ -146,17 +147,35 @@ def _patch_budget(monkeypatch: pytest.MonkeyPatch, *, keep_tokens: int, window: context.auto_compact = True settings = SimpleNamespace( context=context, - llm=SimpleNamespace(api_key=None, api_base=None, timeout=1), + llm=SimpleNamespace(api_key=None, api_base=None, timeout=1, extra_headers=None), ) monkeypatch.setattr(compaction, "load_settings", lambda: settings) -def _patch_summary(monkeypatch: pytest.MonkeyPatch, text: str) -> None: - async def fake_acompletion(**_kwargs: Any) -> Any: - message = SimpleNamespace(content=text) - return SimpleNamespace(choices=[SimpleNamespace(message=message)]) +def _model_response(text: str) -> Any: + chunk = ResponseOutputText(annotations=[], text=text, type="output_text") + message = ResponseOutputMessage( + id="msg", content=[chunk], role="assistant", status="completed", type="message" + ) + return SimpleNamespace(output=[message]) - monkeypatch.setattr("strix.llm.compaction.litellm.acompletion", fake_acompletion) + +def _patch_summary( + monkeypatch: pytest.MonkeyPatch, text: str, captured: dict[str, Any] | None = None +) -> None: + class FakeModel: + async def get_response(self, **kwargs: Any) -> Any: + if captured is not None: + captured.update(kwargs) + return _model_response(text) + + class FakeProvider: + def get_model(self, model_name: str | None) -> Any: + if captured is not None: + captured["model"] = model_name + return FakeModel() + + monkeypatch.setattr(compaction, "StrixProvider", FakeProvider) @pytest.mark.asyncio @@ -189,19 +208,38 @@ async def test_maybe_compact_rewrites_and_keeps_pairs(monkeypatch: pytest.Monkey async def test_maybe_compact_updates_previous_summary(monkeypatch: pytest.MonkeyPatch) -> None: # Window large enough to leave real room for the summary instructions. _patch_budget(monkeypatch, keep_tokens=30, window=4_000) - captured: dict[str, str] = {} - - async def fake_acompletion(**kwargs: Any) -> Any: - captured["prompt"] = kwargs["messages"][0]["content"] - return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="NEW"))]) - - monkeypatch.setattr("strix.llm.compaction.litellm.acompletion", fake_acompletion) + captured: dict[str, Any] = {} + _patch_summary(monkeypatch, "NEW", captured) prior = compaction._checkpoint_item("OLD SUMMARY TEXT") session = FakeSession([prior, *_turns(12)]) assert await compaction.maybe_compact(session, model="m", force=True) is True - assert "OLD SUMMARY TEXT" in captured["prompt"] + assert "OLD SUMMARY TEXT" in captured["input"] + + +@pytest.mark.asyncio +async def test_summarize_routes_through_provider_with_settings( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_budget(monkeypatch, keep_tokens=30, window=4_000) + monkeypatch.setattr( + compaction, + "load_settings", + lambda: SimpleNamespace( + llm=SimpleNamespace( + api_key=None, api_base=None, timeout=1, extra_headers={"X-Feature-Key": "svc"} + ) + ), + ) + captured: dict[str, Any] = {} + _patch_summary(monkeypatch, "S", captured) + + assert await compaction._summarize("litellm/openai/some-model", "p", 64) == "S" + assert captured["model"] == "litellm/openai/some-model" + settings = captured["model_settings"] + assert settings.extra_headers == {"X-Feature-Key": "svc"} + assert settings.max_tokens == 64 def test_fit_to_tokens_truncates_oversized_text(monkeypatch: pytest.MonkeyPatch) -> None: @@ -233,19 +271,14 @@ def test_summary_output_tokens_capped_at_model_limit(monkeypatch: pytest.MonkeyP async def test_maybe_compact_bounds_summary_prompt(monkeypatch: pytest.MonkeyPatch) -> None: # A tiny window with a huge head must not send an oversized summary request. _patch_budget(monkeypatch, keep_tokens=30, window=4_000) - captured: dict[str, str] = {} - - async def fake_acompletion(**kwargs: Any) -> Any: - captured["prompt"] = kwargs["messages"][0]["content"] - return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="S"))]) - - monkeypatch.setattr("strix.llm.compaction.litellm.acompletion", fake_acompletion) + captured: dict[str, Any] = {} + _patch_summary(monkeypatch, "S", captured) big_turns = [{"role": "user", "content": "y" * 2_000} for _ in range(50)] session = FakeSession(big_turns) assert await compaction.maybe_compact(session, model="m") is True # count_tokens==len(chars); prompt must fit the model window. - assert len(captured["prompt"]) <= 4_000 + assert len(captured["input"]) <= 4_000 @pytest.mark.asyncio @@ -256,27 +289,27 @@ async def test_summary_request_fits_when_room_is_below_old_floor( instructions = len(compaction._SUMMARY_INSTRUCTIONS) window = instructions + 64 + 256 + 300 # summary_max(64)+slack(256)+room(300) _patch_budget(monkeypatch, keep_tokens=30, window=window) - captured: dict[str, str] = {} - - async def fake_acompletion(**kwargs: Any) -> Any: - captured["prompt"] = kwargs["messages"][0]["content"] - return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="S"))]) - - monkeypatch.setattr("strix.llm.compaction.litellm.acompletion", fake_acompletion) + captured: dict[str, Any] = {} + _patch_summary(monkeypatch, "S", captured) session = FakeSession([{"role": "user", "content": "y" * 5_000} for _ in range(20)]) assert await compaction.maybe_compact(session, model="m") is True - assert len(captured["prompt"]) <= window + assert len(captured["input"]) <= window @pytest.mark.asyncio async def test_maybe_compact_skips_when_summary_fails(monkeypatch: pytest.MonkeyPatch) -> None: _patch_budget(monkeypatch, keep_tokens=30, window=4_000) - async def fake_acompletion(**_kwargs: Any) -> Any: - raise RuntimeError("boom") + class BoomModel: + async def get_response(self, **_kwargs: Any) -> Any: + raise RuntimeError("boom") - monkeypatch.setattr("strix.llm.compaction.litellm.acompletion", fake_acompletion) + class BoomProvider: + def get_model(self, _model_name: str | None) -> Any: + return BoomModel() + + monkeypatch.setattr(compaction, "StrixProvider", BoomProvider) session = FakeSession(_turns(12)) before = await session.get_items() @@ -290,17 +323,11 @@ async def test_maybe_compact_skips_when_no_room_to_summarise( ) -> None: # No room for any head -> no (doomed) summary is attempted. _patch_budget(monkeypatch, keep_tokens=30, window=200) - called = False - - async def fake_acompletion(**_kwargs: Any) -> Any: - nonlocal called - called = True - return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="S"))]) - - monkeypatch.setattr("strix.llm.compaction.litellm.acompletion", fake_acompletion) + captured: dict[str, Any] = {} + _patch_summary(monkeypatch, "S", captured) session = FakeSession(_turns(12)) before = await session.get_items() assert await compaction.maybe_compact(session, model="m", force=True) is False - assert called is False + assert not captured assert await session.get_items() == before diff --git a/tests/test_dedupe_model.py b/tests/test_dedupe_model.py index 0a0f7654..b17946e9 100644 --- a/tests/test_dedupe_model.py +++ b/tests/test_dedupe_model.py @@ -44,6 +44,38 @@ def test_dedupe_endpoint_sent_per_call() -> None: assert (settings.extra_args or {})["api_key"] == "dedupe-key" +def test_dedicated_dedupe_model_uses_own_headers_not_main() -> None: + dedupe = DedupeSettings( + STRIX_DEDUPE_MODEL="deepseek/cheap", + DEDUPE_LLM_EXTRA_HEADERS={"X-Dedupe": "yes"}, + ) + settings = _dedupe_model_settings(dedupe, "deepseek/cheap", 300) + assert settings.extra_headers == {"X-Dedupe": "yes"} + + +def test_dedicated_dedupe_model_gets_no_main_headers_by_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("LLM_EXTRA_HEADERS", json.dumps({"X-Main": "secret"})) + loader._cached = None + try: + dedupe = DedupeSettings(STRIX_DEDUPE_MODEL="deepseek/cheap") + settings = _dedupe_model_settings(dedupe, "deepseek/cheap", 300) + assert settings.extra_headers is None + finally: + loader._cached = None + + +def test_fallback_dedupe_inherits_main_headers(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LLM_EXTRA_HEADERS", json.dumps({"X-Main": "svc"})) + loader._cached = None + try: + settings = _dedupe_model_settings(DedupeSettings(), "openai/main-model", 300) + assert settings.extra_headers == {"X-Main": "svc"} + finally: + loader._cached = None + + def test_dedupe_defaults_are_empty() -> None: settings = DedupeSettings() assert settings.model is None diff --git a/tests/test_inputs.py b/tests/test_inputs.py index da914879..871ea149 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -272,6 +272,30 @@ def test_make_model_settings_omits_timeout_when_unset() -> None: assert settings.extra_args is None +def test_make_model_settings_sets_extra_headers() -> None: + settings = make_model_settings( + "none", + model_name="openai/some-model", + extra_headers={"X-Feature-Key": "svc", "X-Tenant": "acme"}, + ) + + assert settings.extra_headers == {"X-Feature-Key": "svc", "X-Tenant": "acme"} + + +def test_make_model_settings_omits_extra_headers_when_unset() -> None: + assert make_model_settings("none", model_name="gpt-4o").extra_headers is None + + +def test_make_model_settings_extra_headers_survive_reasoning_resolve() -> None: + settings = make_model_settings( + "high", + model_name="openai/o3", + extra_headers={"X-Feature-Key": "svc"}, + ) + + assert settings.extra_headers == {"X-Feature-Key": "svc"} + + def test_make_model_settings_timeout_survives_reasoning_resolve() -> None: # Reasoning is resolved via ModelSettings.resolve(); the timeout in extra_args # must not be dropped when a reasoning override is merged in. diff --git a/tests/test_runner_rate_limit.py b/tests/test_runner_rate_limit.py index 482c4730..061ad3c5 100644 --- a/tests/test_runner_rate_limit.py +++ b/tests/test_runner_rate_limit.py @@ -40,6 +40,7 @@ async def test_persistent_rate_limit_stops_gracefully( force_required_tool_choice=False, timeout=300, prompt_cache=True, + extra_headers=None, ), runtime=types.SimpleNamespace(max_context_images=3), ) diff --git a/tests/test_runner_root_prompt.py b/tests/test_runner_root_prompt.py index 56d7caa6..cd4d4ac8 100644 --- a/tests/test_runner_root_prompt.py +++ b/tests/test_runner_root_prompt.py @@ -48,6 +48,7 @@ def _patch_engine_scaffold( force_required_tool_choice=False, timeout=300, prompt_cache=True, + extra_headers=None, ), runtime=types.SimpleNamespace(max_context_images=3), ) From 980216860e4965928a992cc6114a038587dd9291 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 30 Jul 2026 05:04:19 +0000 Subject: [PATCH 06/57] feat(llm): opt-in LLM_DISABLE_STREAMING for non-streaming OpenAI-compatible endpoints Some OpenAI-compatible gateways don't support Server-Sent Events (or deliver them unreliably), but the SDK run loop Strix uses only issues streamed requests, so such a gateway fails every turn. Add an opt-in LLM_DISABLE_STREAMING setting that wraps the resolved model in _NonStreamingModel: each turn makes one non-streaming get_response and replays the completed result as a single terminal stream event, so tool calls, usage, and the rest of the agent loop are unchanged. Subscription (ChatGPT) models are always streamed and are not wrapped. --- README.md | 1 + pyproject.toml | 1 + strix/config/models.py | 143 +++++++++++++++++- strix/config/settings.py | 4 + tests/test_disable_streaming.py | 258 ++++++++++++++++++++++++++++++++ 5 files changed, 404 insertions(+), 3 deletions(-) create mode 100644 tests/test_disable_streaming.py diff --git a/README.md b/README.md index 982a635d..47fad3b2 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,7 @@ export LLM_API_KEY="your-api-key" export LLM_API_BASE="your-api-base-url" # if using a local model, e.g. Ollama, LMStudio export PERPLEXITY_API_KEY="your-api-key" # for search capabilities export STRIX_REASONING_EFFORT="high" # control thinking effort (default: high, quick scan: medium) +export LLM_DISABLE_STREAMING="true" # for OpenAI-compatible endpoints that don't support streaming ``` > [!NOTE] diff --git a/pyproject.toml b/pyproject.toml index c9ea8bc1..a24b6a12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -220,6 +220,7 @@ ignore = [ # Stdlib HTTP handler overrides (do_GET/do_POST). "strix/interface/auth_cli.py" = ["N802"] "tests/test_codex_streaming.py" = ["N802"] +"tests/test_disable_streaming.py" = ["N802"] "tests/test_report_pdf.py" = ["S105", "S106"] # Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a # circular dependency with strix.telemetry / strix.interface.viewer.report_pdf. diff --git a/strix/config/models.py b/strix/config/models.py index 7892957d..1c84ef3c 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -5,6 +5,7 @@ from __future__ import annotations import contextlib import inspect import os +import time from typing import TYPE_CHECKING, Any from agents import ( @@ -13,6 +14,8 @@ from agents import ( set_tracing_disabled, ) from agents.model_settings import ModelSettings +from agents.models.fake_id import FAKE_RESPONSES_ID +from agents.models.interface import Model from agents.models.multi_provider import MultiProvider from agents.models.openai_responses import OpenAIResponsesModel from agents.retry import ( @@ -21,6 +24,8 @@ from agents.retry import ( RetryPolicyContext, retry_policies, ) +from openai.types.responses import Response, ResponseCompletedEvent +from openai.types.responses.response_usage import ResponseUsage from openai.types.shared import Reasoning from strix.config import codex @@ -30,8 +35,15 @@ from strix.config.loader import load_settings if TYPE_CHECKING: from collections.abc import AsyncIterator - from agents.models.interface import Model, ModelProvider + from agents.agent_output import AgentOutputSchemaBase + from agents.handoffs import Handoff + from agents.items import ModelResponse, TResponseInputItem, TResponseStreamEvent + from agents.models.interface import ModelProvider, ModelTracing + from agents.retry import ModelRetryAdvice, ModelRetryAdviceRequest + from agents.tool import Tool + from agents.usage import Usage from openai import AsyncOpenAI + from openai.types.responses.response_prompt_param import ResponsePromptParam from strix.config.settings import LlmSettings, ReasoningEffort, Settings @@ -135,6 +147,124 @@ class _CodexResponsesModel(OpenAIResponsesModel): await result +class _NonStreamingModel(Model): + """Serve the SDK's streamed run loop from a single non-streaming request. + + Some OpenAI-compatible gateways do not support Server-Sent Events, or + deliver them unreliably (dropping structured tool-call deltas, or stalling + mid-stream so the whole turn waits out the read timeout). The SDK run loop + Strix uses only issues streamed requests, so such a gateway fails every + turn. Opt in with ``LLM_DISABLE_STREAMING=true`` to wrap the resolved model + so each turn makes one non-streaming ``get_response`` (``stream:false`` on + the wire) and the completed result is replayed as a single terminal stream + event. The run loop then executes tools and emits run items from that final + response exactly as it would for a real stream, so nothing else changes. + """ + + def __init__(self, inner: Model) -> None: + self._inner = inner + + async def close(self) -> None: + await self._inner.close() + + def get_retry_advice(self, request: ModelRetryAdviceRequest) -> ModelRetryAdvice | None: + return self._inner.get_retry_advice(request) + + async def get_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], # noqa: A002 + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchemaBase | None, + handoffs: list[Handoff], + tracing: ModelTracing, + *, + previous_response_id: str | None, + conversation_id: str | None, + prompt: ResponsePromptParam | None, + ) -> ModelResponse: + return await self._inner.get_response( + system_instructions, + input, + model_settings, + tools, + output_schema, + handoffs, + tracing, + previous_response_id=previous_response_id, + conversation_id=conversation_id, + prompt=prompt, + ) + + async def stream_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], # noqa: A002 + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchemaBase | None, + handoffs: list[Handoff], + tracing: ModelTracing, + *, + previous_response_id: str | None, + conversation_id: str | None, + prompt: ResponsePromptParam | None, + ) -> AsyncIterator[TResponseStreamEvent]: + response = await self._inner.get_response( + system_instructions, + input, + model_settings, + tools, + output_schema, + handoffs, + tracing, + previous_response_id=previous_response_id, + conversation_id=conversation_id, + prompt=prompt, + ) + yield _completed_stream_event(response, getattr(self._inner, "model", None)) + + +def _completed_stream_event( + model_response: ModelResponse, model_name: object | None +) -> TResponseStreamEvent: + """Wrap a non-streamed ``ModelResponse`` as the terminal event of a stream. + + The run loop builds its authoritative per-turn response solely from the + ``response.completed`` event, so a single event carrying the full output + and usage is all it needs. + """ + response = Response( + id=model_response.response_id or FAKE_RESPONSES_ID, + created_at=time.time(), + model=str(model_name) if model_name else "", + object="response", + output=list(model_response.output), + tool_choice="auto", + tools=[], + parallel_tool_calls=False, + usage=_response_usage(model_response.usage), + ) + return ResponseCompletedEvent( + response=response, + sequence_number=0, + type="response.completed", + ) + + +def _response_usage(usage: Usage | None) -> ResponseUsage | None: + if usage is None: + return None + return ResponseUsage( + input_tokens=usage.input_tokens, + output_tokens=usage.output_tokens, + total_tokens=usage.total_tokens, + input_tokens_details=usage.input_tokens_details, + output_tokens_details=usage.output_tokens_details, + ) + + class StrixProvider(MultiProvider): """Route any non-OpenAI prefix through LiteLLM with the prefix preserved, so users type ``deepseek/deepseek-chat`` rather than @@ -159,14 +289,21 @@ class StrixProvider(MultiProvider): return self._get_fallback_provider("litellm"), original_model_name def get_model(self, model_name: str | None) -> Model: + llm = load_settings().llm slug = codex.subscription_model(model_name) if slug: + # The ChatGPT subscription backend is always streamed; it has no + # non-streaming mode to fall back to, so LLM_DISABLE_STREAMING + # does not apply here. return _CodexResponsesModel( slug, codex.get_subscription_client(), - reasoning_effort=load_settings().llm.reasoning_effort, + reasoning_effort=llm.reasoning_effort, ) - return super().get_model(model_name) + model = super().get_model(model_name) + if llm.disable_streaming: + return _NonStreamingModel(model) + return model DEFAULT_MODEL_RETRY = ModelRetrySettings( diff --git a/strix/config/settings.py b/strix/config/settings.py index 016a8ad9..e53d125c 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -48,6 +48,10 @@ class LlmSettings(BaseSettings): default=True, alias="STRIX_PROMPT_CACHE", ) + disable_streaming: bool = Field( + default=False, + alias="LLM_DISABLE_STREAMING", + ) timeout: int = Field(default=300, alias="LLM_TIMEOUT") diff --git a/tests/test_disable_streaming.py b/tests/test_disable_streaming.py new file mode 100644 index 00000000..16afafbf --- /dev/null +++ b/tests/test_disable_streaming.py @@ -0,0 +1,258 @@ +"""Tests for LLM_DISABLE_STREAMING: serve the streamed run loop without SSE. + +A gateway that rejects ``stream:true`` (or delivers SSE unreliably) breaks the +SDK run loop, which only issues streamed requests. ``_NonStreamingModel`` wraps +the resolved model so each turn makes one non-streaming ``get_response`` and +replays the completed result as a single terminal stream event. A local server +that rejects streamed requests but answers non-streamed ones — including a +structured tool call — proves the wrapper works where the stock model fails. +""" + +from __future__ import annotations + +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import TYPE_CHECKING, Any + +import pytest +from agents.model_settings import ModelSettings +from agents.models.interface import Model, ModelTracing +from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel +from openai import AsyncOpenAI, BadRequestError +from openai.types.responses import ( + ResponseCompletedEvent, + ResponseFunctionToolCall, + ResponseOutputMessage, + ResponseOutputText, +) + +from strix.config import codex, loader +from strix.config.loader import load_settings +from strix.config.models import StrixProvider, _NonStreamingModel + + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Iterator + + +def _tool_call_completion() -> dict[str, Any]: + return { + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 0, + "model": "gw-model", + "choices": [ + { + "index": 0, + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "do_thing", "arguments": '{"n": 1}'}, + } + ], + }, + } + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7}, + } + + +def _text_completion() -> dict[str, Any]: + return { + "id": "chatcmpl-2", + "object": "chat.completion", + "created": 0, + "model": "gw-model", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant", "content": "hello from gateway"}, + } + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}, + } + + +_CAPTURED: dict[str, Any] = {} +_PAYLOAD: dict[str, dict[str, Any]] = {"value": _tool_call_completion()} + + +class _Handler(BaseHTTPRequestHandler): + """A gateway that only speaks non-streaming Chat Completions.""" + + def log_message(self, *args: Any) -> None: + pass + + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length) or b"{}") + _CAPTURED.clear() + _CAPTURED.update(body) + if body.get("stream"): + payload = json.dumps( + {"error": {"message": "streaming is not supported by this endpoint"}} + ).encode() + self.send_response(400) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + payload = json.dumps(_PAYLOAD["value"]).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + +@pytest.fixture +def gateway_url() -> Iterator[str]: + _PAYLOAD["value"] = _tool_call_completion() + server = HTTPServer(("127.0.0.1", 0), _Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_address[1]}/v1" + finally: + server.shutdown() + server.server_close() + + +def _model(base_url: str) -> OpenAIChatCompletionsModel: + client = AsyncOpenAI(api_key="tok", base_url=base_url) + return OpenAIChatCompletionsModel(model="gw-model", openai_client=client) + + +def _call_kwargs() -> dict[str, Any]: + return { + "system_instructions": "s", + "input": "hi", + "model_settings": ModelSettings(), + "tools": [], + "output_schema": None, + "handoffs": [], + "tracing": ModelTracing.DISABLED, + "previous_response_id": None, + "conversation_id": None, + "prompt": None, + } + + +async def _drain(gen: AsyncIterator[Any]) -> list[Any]: + return [event async for event in gen] + + +@pytest.mark.asyncio +async def test_stock_model_streaming_fails_on_non_streaming_gateway(gateway_url: str) -> None: + # The stock model issues stream:true and the gateway rejects it. + model = _model(gateway_url) + with pytest.raises(BadRequestError, match="streaming is not supported"): + await _drain(model.stream_response(**_call_kwargs())) + assert _CAPTURED["stream"] is True + + +@pytest.mark.asyncio +async def test_wrapper_streams_tool_call_without_streaming_request(gateway_url: str) -> None: + # The wrapper turns the streamed run-loop call into one non-streaming + # request and replays the completed result as a terminal stream event. + model = _NonStreamingModel(_model(gateway_url)) + events = await _drain(model.stream_response(**_call_kwargs())) + + assert _CAPTURED.get("stream") is not True + assert len(events) == 1 + completed = events[0] + assert isinstance(completed, ResponseCompletedEvent) + + tool_call = completed.response.output[0] + assert isinstance(tool_call, ResponseFunctionToolCall) + assert tool_call.name == "do_thing" + assert json.loads(tool_call.arguments) == {"n": 1} + + assert completed.response.usage is not None + assert completed.response.usage.total_tokens == 7 + + +@pytest.mark.asyncio +async def test_wrapper_streams_plain_text(gateway_url: str) -> None: + _PAYLOAD["value"] = _text_completion() + model = _NonStreamingModel(_model(gateway_url)) + events = await _drain(model.stream_response(**_call_kwargs())) + + assert _CAPTURED.get("stream") is not True + message = events[0].response.output[0] + assert isinstance(message, ResponseOutputMessage) + text = message.content[0] + assert isinstance(text, ResponseOutputText) + assert text.text == "hello from gateway" + + +@pytest.mark.asyncio +async def test_wrapper_get_response_stays_non_streaming(gateway_url: str) -> None: + # The non-streaming path is a plain pass-through to the inner model. + model = _NonStreamingModel(_model(gateway_url)) + response = await model.get_response(**_call_kwargs()) + assert _CAPTURED.get("stream") is not True + tool_call = response.output[0] + assert isinstance(tool_call, ResponseFunctionToolCall) + assert tool_call.name == "do_thing" + + +class _DummyModel(Model): + async def get_response(self, *args: Any, **kwargs: Any) -> Any: + raise NotImplementedError + + def stream_response(self, *args: Any, **kwargs: Any) -> Any: + raise NotImplementedError + + +@pytest.fixture +def _reset_settings(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + for key in ("STRIX_LLM", "LLM_DISABLE_STREAMING"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setattr(loader, "_cached", None) + monkeypatch.setattr(loader, "_override", None) + yield + + +def test_get_model_wraps_when_disabled( + monkeypatch: pytest.MonkeyPatch, _reset_settings: None +) -> None: + inner = _DummyModel() + monkeypatch.setattr("strix.config.models.MultiProvider.get_model", lambda *_: inner) + monkeypatch.setenv("LLM_DISABLE_STREAMING", "true") + load_settings() + + model = StrixProvider().get_model("openai/gpt-4o-mini") + assert isinstance(model, _NonStreamingModel) + + +def test_get_model_unwrapped_by_default( + monkeypatch: pytest.MonkeyPatch, _reset_settings: None +) -> None: + inner = _DummyModel() + monkeypatch.setattr("strix.config.models.MultiProvider.get_model", lambda *_: inner) + load_settings() + + model = StrixProvider().get_model("openai/gpt-4o-mini") + assert model is inner + + +def test_get_model_does_not_wrap_subscription_model( + monkeypatch: pytest.MonkeyPatch, _reset_settings: None +) -> None: + # Subscription (ChatGPT) models are always streamed and must not be wrapped. + monkeypatch.setattr(codex, "subscription_model", lambda *_: "gpt-5.5") + monkeypatch.setattr(codex, "get_subscription_client", lambda: AsyncOpenAI(api_key="x")) + monkeypatch.setenv("LLM_DISABLE_STREAMING", "true") + load_settings() + + model = StrixProvider().get_model("gpt-5.5") + assert not isinstance(model, _NonStreamingModel) From 885b2ca5c55d5295f64c563da127cb2909e1fc30 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 30 Jul 2026 05:10:05 +0000 Subject: [PATCH 07/57] test(llm): cover the full run loop against a non-streaming gateway; drop README note Adds an integration test that drives Runner.run_streamed against a non-streaming gateway through _NonStreamingModel: the synthetic terminal event feeds the runner, which executes the tool call and continues to a final answer over two non-streaming turns. Removes the README env-var note. --- README.md | 1 - tests/test_disable_streaming.py | 70 ++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 47fad3b2..982a635d 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,6 @@ export LLM_API_KEY="your-api-key" export LLM_API_BASE="your-api-base-url" # if using a local model, e.g. Ollama, LMStudio export PERPLEXITY_API_KEY="your-api-key" # for search capabilities export STRIX_REASONING_EFFORT="high" # control thinking effort (default: high, quick scan: medium) -export LLM_DISABLE_STREAMING="true" # for OpenAI-compatible endpoints that don't support streaming ``` > [!NOTE] diff --git a/tests/test_disable_streaming.py b/tests/test_disable_streaming.py index 16afafbf..1d667e61 100644 --- a/tests/test_disable_streaming.py +++ b/tests/test_disable_streaming.py @@ -16,9 +16,11 @@ from http.server import BaseHTTPRequestHandler, HTTPServer from typing import TYPE_CHECKING, Any import pytest +from agents import Agent, Runner, function_tool from agents.model_settings import ModelSettings -from agents.models.interface import Model, ModelTracing +from agents.models.interface import Model, ModelProvider, ModelTracing from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel +from agents.run import RunConfig from openai import AsyncOpenAI, BadRequestError from openai.types.responses import ( ResponseCompletedEvent, @@ -205,6 +207,72 @@ async def test_wrapper_get_response_stays_non_streaming(gateway_url: str) -> Non assert tool_call.name == "do_thing" +_TURN_STREAM_FLAGS: list[bool] = [] + + +class _MultiTurnHandler(BaseHTTPRequestHandler): + """Non-streaming gateway: a tool call on turn 1, a final answer on turn 2.""" + + def log_message(self, *args: Any) -> None: + pass + + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length) or b"{}") + _TURN_STREAM_FLAGS.append(bool(body.get("stream"))) + completion = _tool_call_completion() if len(_TURN_STREAM_FLAGS) == 1 else _text_completion() + if len(_TURN_STREAM_FLAGS) > 1: + completion["choices"][0]["message"]["content"] = "all done" + payload = json.dumps(completion).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + +@pytest.fixture +def multiturn_url() -> Iterator[str]: + _TURN_STREAM_FLAGS.clear() + server = HTTPServer(("127.0.0.1", 0), _MultiTurnHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_address[1]}/v1" + finally: + server.shutdown() + server.server_close() + + +@pytest.mark.asyncio +async def test_run_loop_executes_tool_and_completes_without_streaming(multiturn_url: str) -> None: + # The whole streamed agent loop runs against a non-streaming gateway: the + # synthetic terminal event feeds the runner, which executes the tool and + # continues the turn until a final answer. + calls: list[int] = [] + + @function_tool + def do_thing(n: int) -> str: + calls.append(n) + return f"did {n}" + + class _Provider(ModelProvider): + def get_model(self, model_name: str | None) -> Model: # noqa: ARG002 + return _NonStreamingModel(_model(multiturn_url)) + + agent = Agent(name="t", instructions="use the tool", tools=[do_thing], model="gw-model") + result = Runner.run_streamed( + agent, input="please", run_config=RunConfig(model_provider=_Provider()) + ) + async for _ in result.stream_events(): + pass + + assert calls == [1] # tool executed with the streamed tool-call args + assert result.final_output == "all done" + assert len(_TURN_STREAM_FLAGS) == 2 # two turns, both... + assert not any(_TURN_STREAM_FLAGS) # ...issued as non-streaming requests + + class _DummyModel(Model): async def get_response(self, *args: Any, **kwargs: Any) -> Any: raise NotImplementedError From 76e97e6a59fba11a2467d56f37c25d4762972a65 Mon Sep 17 00:00:00 2001 From: chunguscodes Date: Fri, 31 Jul 2026 00:01:04 +0100 Subject: [PATCH 08/57] fix(llm): avoid auth during ChatGPT lookup LiteLLM treats provider-qualified metadata lookups as an auth path. Use the underlying model slug so context sizing cannot block the scan loop in a device-code poll. --- strix/llm/context_budget.py | 15 +++++++++++++-- tests/test_context_budget.py | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/strix/llm/context_budget.py b/strix/llm/context_budget.py index 3f153d91..b7589a9e 100644 --- a/strix/llm/context_budget.py +++ b/strix/llm/context_budget.py @@ -17,7 +17,14 @@ logger = logging.getLogger(__name__) # LiteLLM keys models without the routing prefix users type (``openai/``, # ``litellm/``, ``ollama/`` ...). Strip a leading provider segment on lookup. -_STRIPPABLE_PREFIXES = ("openai/", "litellm/", "any-llm/", "ollama/", "ollama_chat/") +_STRIPPABLE_PREFIXES = ( + "openai/", + "chatgpt/", + "litellm/", + "any-llm/", + "ollama/", + "ollama_chat/", +) _DEFAULT_OUTPUT_TOKENS = 8_192 @@ -38,7 +45,11 @@ def _safe_get_model_info(model: str) -> dict[str, Any] | None: @lru_cache(maxsize=128) def _model_info(model: str) -> dict[str, int]: - for candidate in (model, _lookup_key(model)): + lookup_key = _lookup_key(model) + # Provider-qualified ChatGPT lookups may start a synchronous device-login + # poll. LiteLLM keys the metadata by the underlying model slug. + candidates = (lookup_key,) if model.startswith("chatgpt/") else (model, lookup_key) + for candidate in candidates: info = _safe_get_model_info(candidate) if info is not None: return { diff --git a/tests/test_context_budget.py b/tests/test_context_budget.py index 8b34b483..a9a48e0c 100644 --- a/tests/test_context_budget.py +++ b/tests/test_context_budget.py @@ -21,6 +21,24 @@ def test_context_window_strips_provider_prefix() -> None: assert context_budget.context_window("openai/gpt-4o") == 128_000 +def test_context_window_chatgpt_prefix_skips_provider_auth( + monkeypatch: pytest.MonkeyPatch, +) -> None: + context_budget._model_info.cache_clear() + calls: list[str] = [] + + def _model_info(model: str) -> dict[str, int]: + calls.append(model) + return {"max_input_tokens": 1_050_000, "max_output_tokens": 128_000} + + monkeypatch.setattr("strix.llm.context_budget.litellm.get_model_info", _model_info) + try: + assert context_budget.context_window("chatgpt/gpt-5.6-luna") == 1_050_000 + assert calls == ["gpt-5.6-luna"] + finally: + context_budget._model_info.cache_clear() + + def test_context_window_unmapped_uses_fallback(monkeypatch: pytest.MonkeyPatch) -> None: context_budget._model_info.cache_clear() From a9deb84260c3b38789c01c8260bb288b19f6062b Mon Sep 17 00:00:00 2001 From: alex s <46074070+bearsyankees@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:57:42 -0400 Subject: [PATCH 09/57] fix(llm): surface structured provider refusals (#944) * fix(llm): surface structured provider refusals * fix(llm): settle refused autonomous agents --- strix/core/execution.py | 24 +++++++++++ tests/test_execution.py | 90 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/strix/core/execution.py b/strix/core/execution.py index ae726ed3..7272a8fe 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -55,6 +55,23 @@ _INPUT_REJECTION_CODES = frozenset({400, 404, 422}) _MAX_COMPACTIONS_PER_CYCLE = 2 +class ProviderRefusalError(AgentsException): + """Raised when a provider returns a structured refusal instead of an exception.""" + + +def _structured_provider_refusal(result: Any) -> str | None: + for item in getattr(result, "new_items", ()) or (): + raw_item = getattr(item, "raw_item", None) + for content in getattr(raw_item, "content", ()) or (): + if getattr(content, "type", None) != "refusal": + continue + refusal = getattr(content, "refusal", None) + if isinstance(refusal, str) and refusal.strip(): + return refusal.strip() + return "The model provider refused this request." + return None + + def _run_config_model(run_config: RunConfig) -> str | None: return run_config.model if isinstance(run_config.model, str) else None @@ -490,6 +507,8 @@ async def _run_cycle( # noqa: PLR0912, PLR0915 logger.exception("stream event sink failed for %s", agent_id) if stream.run_loop_exception is not None: raise stream.run_loop_exception + if refusal := _structured_provider_refusal(stream): + raise ProviderRefusalError(refusal) except (BudgetExceededError, BudgetPausedError, SubagentBudgetReservedError): raise except RuntimeError as stream_exc: @@ -584,6 +603,11 @@ async def _run_cycle( # noqa: PLR0912, PLR0915 return await _handle_content_guardrail( coordinator, agent_id, exc, interactive=interactive ) + if isinstance(exc, ProviderRefusalError): + logger.warning("agent %s refused by the model provider: %s", agent_id, exc) + await coordinator.set_status(agent_id, "failed", error=str(exc)) + await _notify_parent_on_terminal(coordinator, agent_id, "failed") + return None if not interactive: raise if isinstance(exc, MaxTurnsExceeded): diff --git a/tests/test_execution.py b/tests/test_execution.py index 2ecd9a84..8fa043f1 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -9,8 +9,10 @@ from typing import Any from unittest.mock import MagicMock import pytest +from agents.items import MessageOutputItem from agents.memory import SQLiteSession from agents.tool_context import ToolContext +from openai.types.responses import ResponseOutputMessage, ResponseOutputRefusal from strix.config import codex from strix.core import execution @@ -24,6 +26,30 @@ from strix.core.execution import ( from strix.tools.finish.tool import finish_scan +class _StructuredRefusalStream: + def __init__(self, refusal: str) -> None: + self.run_loop_exception: BaseException | None = None + self.new_items = [ + MessageOutputItem( + agent=MagicMock(), + raw_item=ResponseOutputMessage( + id="msg-refusal", + content=[ResponseOutputRefusal(type="refusal", refusal=refusal)], + role="assistant", + status="completed", + type="message", + ), + ) + ] + + async def stream_events(self) -> Any: + if False: + yield None + + def cancel(self, mode: str = "immediate") -> None: # noqa: ARG002 + return + + async def _call_finish_scan( coordinator: AgentCoordinator, agent_id: str, parent_id: str | None ) -> dict[str, Any]: @@ -544,6 +570,70 @@ async def test_guardrail_noninteractive_fails_only_blocked_agent(tmp_path: Any) session.close() +@pytest.mark.asyncio +async def test_structured_provider_refusal_fails_interactive_agent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + refusal = "This request was blocked under the provider's usage policy." + stream = _StructuredRefusalStream(refusal) + monkeypatch.setattr(execution.Runner, "run_streamed", lambda *_args, **_kwargs: stream) + coordinator = AgentCoordinator() + await coordinator.register("root", "strix", parent_id=None) + + result = await execution._run_cycle( + MagicMock(), + coordinator, + "root", + input_data="task", + run_config=MagicMock(), + context={}, + max_turns=5, + session=None, + interactive=True, + event_sink=None, + hooks=None, + ) + + assert result is None + assert coordinator.statuses["root"] == "failed" + assert coordinator.errors["root"] == refusal + + +@pytest.mark.asyncio +async def test_structured_provider_refusal_fails_noninteractive_child( + tmp_path: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + refusal = "This request was blocked under the provider's usage policy." + stream = _StructuredRefusalStream(refusal) + monkeypatch.setattr(execution.Runner, "run_streamed", lambda *_args, **_kwargs: stream) + coordinator = AgentCoordinator() + await coordinator.register("root", "strix", parent_id=None) + await coordinator.register("child", "recon", parent_id="root") + session = SQLiteSession("root", tmp_path / "agents.db") + await coordinator.attach_runtime("root", session=session) + + result = await execution._run_cycle( + MagicMock(), + coordinator, + "child", + input_data="task", + run_config=MagicMock(), + context={"parent_id": "root"}, + max_turns=5, + session=None, + interactive=False, + event_sink=None, + hooks=None, + ) + + assert result is None + assert coordinator.statuses["child"] == "failed" + assert coordinator.errors["child"] == refusal + assert coordinator.pending_counts.get("root", 0) > 0 + session.close() + + @pytest.mark.asyncio async def test_resume_revives_guardrail_parked_child_but_not_plain_waiting( tmp_path: Any, monkeypatch: pytest.MonkeyPatch From 5602bc23cac312f7f98080d46ed5dc30aa3eed2e Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:17:08 -0700 Subject: [PATCH 10/57] =?UTF-8?q?fix:=20pre-v1-style=20lifecycle=20resilie?= =?UTF-8?q?nce=20=E2=80=94=20mailbox=20delivery,=20uniform=20revival,=20un?= =?UTF-8?q?exitable=20runner,=20waiting=20timeout,=20broader=20retries,=20?= =?UTF-8?q?crash-safe=20identity=20(#923)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- strix/core/agents.py | 96 ++++++---- strix/core/execution.py | 183 ++++++++++++++----- strix/core/runner.py | 8 +- strix/core/sessions.py | 13 ++ strix/interface/tui/app.py | 8 +- strix/interface/tui/live_view.py | 2 +- tests/test_execution.py | 227 ++++++++++++++++++------ tests/test_execution_transient_retry.py | 21 ++- 8 files changed, 413 insertions(+), 145 deletions(-) diff --git a/strix/core/agents.py b/strix/core/agents.py index 97d94548..3f20629d 100644 --- a/strix/core/agents.py +++ b/strix/core/agents.py @@ -32,6 +32,8 @@ class AgentRuntime: stream: Any | None = None interrupt_on_message: bool = False wake: asyncio.Event = field(default_factory=asyncio.Event) + mailbox: list[dict[str, Any]] = field(default_factory=list) + user_wake_required: bool = False class AgentCoordinator: @@ -179,6 +181,7 @@ class AgentCoordinator: if agent_id in self.statuses: self.statuses[agent_id] = "running" self.errors.pop(agent_id, None) + self.runtimes.setdefault(agent_id, AgentRuntime()).user_wake_required = False await self._maybe_snapshot() async def park_waiting(self, agent_id: str) -> None: @@ -196,6 +199,7 @@ class AgentCoordinator: elif status == "running": self.errors.pop(agent_id, None) runtime = self.runtimes.setdefault(agent_id, AgentRuntime()) + runtime.user_wake_required = status in {"failed", "crashed"} runtime.wake.set() logger.info("agent.status %s=%s", agent_id, status) await self._maybe_snapshot() @@ -203,49 +207,47 @@ class AgentCoordinator: async def send( self, target_agent_id: str, message: dict[str, Any], *, interrupt: bool = True ) -> bool: - """Deliver a user/peer message by appending it to the target SDK session.""" - if message.get("from") == "user" and self._budget_paused: + """Queue a user/peer message in the target's mailbox and wake it.""" + from_user = message.get("from") == "user" + if from_user and self._budget_paused: await self.resume_from_budget_pause(exclude=target_agent_id) async with self._lock: if target_agent_id not in self.statuses: logger.debug("agent.send dropped unknown target=%s", target_agent_id) return False runtime = self.runtimes.setdefault(target_agent_id, AgentRuntime()) - session = runtime.session + runtime.mailbox.append(dict(message)) + self.pending_counts[target_agent_id] = self.pending_counts.get(target_agent_id, 0) + 1 + if from_user: + runtime.user_wake_required = False + runtime.wake.set() stream = runtime.stream interrupt_on_message = runtime.interrupt_on_message - if session is None: - logger.warning( - "agent.send dropped target=%s because its SDK session is not attached", - target_agent_id, - ) - return False - try: - async with session_write_lock(session): - await session.add_items([self._message_to_session_item(message)]) - except Exception: - logger.exception( - "agent.send failed to append to SDK session target=%s", - target_agent_id, - ) - return False - async with self._lock: - self.pending_counts[target_agent_id] = self.pending_counts.get(target_agent_id, 0) + 1 - self.runtimes.setdefault(target_agent_id, AgentRuntime()).wake.set() if stream is not None and interrupt and interrupt_on_message: stream.cancel(mode="immediate") await self._maybe_snapshot() return True - async def wait_for_message(self, agent_id: str) -> None: + async def wait_for_message(self, agent_id: str, *, timeout: float | None = None) -> bool: + """Wait until a message is ready for ``agent_id``; False on ``timeout``.""" while True: async with self._lock: + runtime = self.runtimes.setdefault(agent_id, AgentRuntime()) reserve_exit = self._reserve_stopped and self.parent_of.get(agent_id) is not None - if self._budget_stopped or reserve_exit or self.pending_counts.get(agent_id, 0) > 0: - return - wake = self.runtimes.setdefault(agent_id, AgentRuntime()).wake + pending_ready = ( + self.pending_counts.get(agent_id, 0) > 0 and not runtime.user_wake_required + ) + if self._budget_stopped or reserve_exit or pending_ready: + return True + wake = runtime.wake wake.clear() - await wake.wait() + if timeout is None: + await wake.wait() + else: + try: + await asyncio.wait_for(wake.wait(), timeout) + except TimeoutError: + return False async def consume_pending( self, @@ -253,17 +255,38 @@ class AgentCoordinator: *, include_items: bool = False, ) -> tuple[int, list[Any]]: + """Drain the agent's mailbox into its own SDK session.""" async with self._lock: - count = self.pending_counts.get(agent_id, 0) + runtime = self.runtimes.setdefault(agent_id, AgentRuntime()) + queued = list(runtime.mailbox) + runtime.mailbox.clear() + count = max(self.pending_counts.get(agent_id, 0), len(queued)) self.pending_counts[agent_id] = 0 - session = self.runtimes.get(agent_id, AgentRuntime()).session + session = runtime.session if count <= 0: return 0, [] + items = [self._message_to_session_item(m) for m in queued] + if items: + if session is None: + logger.warning( + "agent %s has no SDK session attached; %d queued messages were not persisted", + agent_id, + len(items), + ) + else: + try: + async with session_write_lock(session): + await session.add_items(items) + except Exception: + logger.exception( + "failed to append %d queued messages to the session of %s", + len(items), + agent_id, + ) await self._maybe_snapshot() - if not include_items or session is None: + if not include_items: return count, [] - items = await session.get_items() - return count, list(items[-count:]) + return count, items async def request_stop(self, agent_id: str) -> None: async with self._lock: @@ -374,6 +397,11 @@ class AgentCoordinator: "names": dict(self.names), "metadata": {aid: dict(md) for aid, md in self.metadata.items()}, "pending_counts": dict(self.pending_counts), + "mailboxes": { + aid: [dict(m) for m in runtime.mailbox] + for aid, runtime in self.runtimes.items() + if runtime.mailbox + }, "errors": dict(self.errors), "budget_stopped": self._budget_stopped, "reserve_stopped": self._reserve_stopped, @@ -388,6 +416,12 @@ class AgentCoordinator: self.metadata = {aid: dict(md) for aid, md in snap.get("metadata", {}).items()} self.pending_counts = dict(snap.get("pending_counts", {})) self.errors = dict(snap.get("errors", {})) + mailboxes = snap.get("mailboxes", {}) + if isinstance(mailboxes, dict): + for aid, msgs in mailboxes.items(): + if isinstance(msgs, list): + runtime = self.runtimes.setdefault(aid, AgentRuntime()) + runtime.mailbox = [dict(m) for m in msgs if isinstance(m, dict)] self._budget_stopped = bool(snap.get("budget_stopped", False)) self._reserve_stopped = bool(snap.get("reserve_stopped", False)) self._budget_paused = bool(snap.get("budget_paused", False)) diff --git a/strix/core/execution.py b/strix/core/execution.py index 7272a8fe..762fcd3f 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -9,6 +9,7 @@ import uuid from collections.abc import Callable from typing import TYPE_CHECKING, Any, cast +import litellm from agents import RunConfig, Runner from agents.exceptions import AgentsException, MaxTurnsExceeded, UserError from agents.sandbox.errors import ExecTransportError @@ -16,9 +17,7 @@ from docker import errors as docker_errors # type: ignore[import-untyped, unuse from openai import ( APIConnectionError, APIError, - APIStatusError, APITimeoutError, - RateLimitError, ) from strix.config import codex @@ -31,6 +30,8 @@ from strix.core.inputs import child_initial_input from strix.core.sessions import ( enforce_image_budget, open_agent_session, + replace_session_items, + seed_initial_input, strip_all_images_from_session, ) from strix.llm.compaction import is_context_overflow, maybe_compact @@ -106,15 +107,9 @@ async def _compact_session( ) -_GUARDRAIL_PARK_ERROR = ( - "Blocked by the model's content guardrail (flagged as a possible cybersecurity risk). " - "Set STRIX_LLM to a model that isn't blocked and resume the scan to continue." -) - -_TRANSIENT_MODEL_STATUS_CODES = frozenset({408, 500, 502, 503, 504}) -_MAX_TRANSIENT_MODEL_RETRIES = 4 +_MAX_TRANSIENT_MODEL_RETRIES = 5 _TRANSIENT_MODEL_RETRY_BASE_DELAY_S = 2.0 -_TRANSIENT_MODEL_RETRY_MAX_DELAY_S = 30.0 +_TRANSIENT_MODEL_RETRY_MAX_DELAY_S = 90.0 def _model_error_status_code(exc: BaseException) -> int | None: @@ -123,15 +118,16 @@ def _model_error_status_code(exc: BaseException) -> int | None: def _is_transient_model_error(exc: BaseException) -> bool: - if isinstance(exc, RateLimitError): + if codex.is_content_guardrail_error(exc): return False - if isinstance(exc, APITimeoutError | APIConnectionError): + if isinstance( + exc, APITimeoutError | APIConnectionError | TimeoutError | ConnectionError | OSError + ): return True - if isinstance(exc, APIStatusError): - return exc.status_code in _TRANSIENT_MODEL_STATUS_CODES - if isinstance(exc, APIError): - return _model_error_status_code(exc) is None - return False + code = _model_error_status_code(exc) + if code is not None: + return bool(litellm._should_retry(code)) + return isinstance(exc, APIError) def _transient_model_retry_delay(attempt: int) -> float: @@ -139,6 +135,40 @@ def _transient_model_retry_delay(attempt: int) -> float: return min(delay, _TRANSIENT_MODEL_RETRY_MAX_DELAY_S) +async def _salvage_stream_to_session( + session: Session, + pre_run_items: list[Any], + stream: Any, + agent_id: str, +) -> None: + """Persist a crashed run's full history so a revived agent loses no context.""" + if stream is None: + return + try: + replay = list(stream.to_input_list()) + except Exception: + logger.exception("could not build salvage history for %s", agent_id) + return + desired = list(pre_run_items) + replay + if len(desired) <= len(pre_run_items): + return + try: + await replace_session_items(session, desired) + except Exception: + logger.exception("salvaging crashed run history failed for %s", agent_id) + + +async def _seed_and_prepare_first_input( + session: Session | None, initial_input: Any, *, start_parked: bool +) -> Any: + """Persist the opening input up front so it survives a first-turn crash.""" + if initial_input and session is not None and not start_parked: + with contextlib.suppress(Exception): + if await seed_initial_input(session, initial_input): + return [] + return initial_input + + async def run_agent_loop( *, agent: Any, @@ -161,6 +191,10 @@ async def run_agent_loop( ) result: RunResultBase | None = None + first_cycle_input = await _seed_and_prepare_first_input( + session, initial_input, start_parked=start_parked + ) + budget_stopped = coordinator.budget_stopped reserve_stopped = coordinator.reserve_stopped if budget_stopped: @@ -176,16 +210,15 @@ async def run_agent_loop( if not (start_parked and interactive): if interactive: with contextlib.suppress(BudgetPausedError): - result = await _run_cycle( + result = await _run_cycle_parked( agent, coordinator, agent_id, - input_data=initial_input, + input_data=first_cycle_input, run_config=run_config, context=context, max_turns=max_turns, session=session, - interactive=interactive, event_sink=event_sink, hooks=hooks, ) @@ -194,7 +227,7 @@ async def run_agent_loop( agent, coordinator, agent_id, - initial_input=initial_input, + initial_input=first_cycle_input, run_config=run_config, context=context, max_turns=max_turns, @@ -207,8 +240,9 @@ async def run_agent_loop( return result while True: + timeout = await _plain_waiting_timeout(coordinator, agent_id, context) try: - await coordinator.wait_for_message(agent_id) + woke = await coordinator.wait_for_message(agent_id, timeout=timeout) except asyncio.CancelledError: return result @@ -220,9 +254,21 @@ async def run_agent_loop( await coordinator.set_status(agent_id, "stopped") raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve") + if not woke: + logger.info("agent %s reached its waiting timeout; auto-resuming", agent_id) + await coordinator.send( + agent_id, + { + "from": "system", + "type": "auto_resume", + "content": "Waiting timeout reached. Resuming execution.", + }, + interrupt=False, + ) + await coordinator.consume_pending(agent_id) with contextlib.suppress(BudgetPausedError): - result = await _run_cycle( + result = await _run_cycle_parked( agent, coordinator, agent_id, @@ -231,7 +277,6 @@ async def run_agent_loop( context=context, max_turns=max_turns, session=session, - interactive=interactive, event_sink=event_sink, hooks=hooks, ) @@ -327,7 +372,6 @@ async def respawn_subagents( if coordinator.parent_of.get(aid) is None or aid == root_id: continue md["_restored_status"] = status - md["_restored_error"] = coordinator.errors.get(aid) candidates.append( ( aid, @@ -340,8 +384,7 @@ async def respawn_subagents( for child_id, name, parent_id, md in candidates: try: restored_status = str(md.get("_restored_status") or "running") - recoverable_park = restored_status == "waiting" and bool(md.get("_restored_error")) - start_parked = interactive and restored_status != "running" and not recoverable_park + start_parked = interactive and restored_status != "running" if start_parked: logger.warning( @@ -456,6 +499,64 @@ async def _run_noninteractive_until_lifecycle( ) +_WAITING_AUTO_RESUME_TIMEOUT_S = 600.0 + + +async def _plain_waiting_timeout( + coordinator: AgentCoordinator, + agent_id: str, + context: dict[str, Any], +) -> float | None: + """Auto-resume timeout for a plainly-waiting subagent; None waits forever.""" + if context.get("parent_id") is None: + return None + async with coordinator._lock: + status = coordinator.statuses.get(agent_id) + has_error = agent_id in coordinator.errors + runtime = coordinator.runtimes.get(agent_id) + gated = runtime.user_wake_required if runtime is not None else False + if status == "waiting" and not has_error and not gated: + return _WAITING_AUTO_RESUME_TIMEOUT_S + return None + + +async def _run_cycle_parked( + agent: Any, + coordinator: AgentCoordinator, + agent_id: str, + *, + input_data: Any, + run_config: RunConfig, + context: dict[str, Any], + max_turns: int, + session: Session | None, + event_sink: StreamEventSink | None, + hooks: RunHooks[dict[str, Any]] | None, +) -> RunResultBase | None: + """Interactive run cycle that parks on any error instead of killing the runner.""" + try: + return await _run_cycle( + agent, + coordinator, + agent_id, + input_data=input_data, + run_config=run_config, + context=context, + max_turns=max_turns, + session=session, + interactive=True, + event_sink=event_sink, + hooks=hooks, + ) + except (BudgetExceededError, BudgetPausedError, SubagentBudgetReservedError): + raise + except Exception as exc: + logger.exception("error escaped the run cycle for %s; parking as failed", agent_id) + await coordinator.set_status(agent_id, "failed", error=str(exc) or type(exc).__name__) + await _notify_parent_on_terminal(coordinator, agent_id, "failed") + return None + + async def _run_cycle( # noqa: PLR0912, PLR0915 agent: Any, coordinator: AgentCoordinator, @@ -474,6 +575,8 @@ async def _run_cycle( # noqa: PLR0912, PLR0915 compactions = 0 model_retries = 0 while True: + stream: Any = None + pre_run_items: list[Any] = [] try: await coordinator.mark_running(agent_id) if session is not None: @@ -487,6 +590,8 @@ async def _run_cycle( # noqa: PLR0912, PLR0915 await _compact_session(agent, session, run_config, force=False) except Exception: logger.exception("proactive compaction failed for %s", agent_id) + with contextlib.suppress(Exception): + pre_run_items = list(await session.get_items()) stream = Runner.run_streamed( agent, input=input_data, @@ -599,10 +704,8 @@ async def _run_cycle( # noqa: PLR0912, PLR0915 if session is not None: input_data = [] continue - if codex.is_content_guardrail_error(exc): - return await _handle_content_guardrail( - coordinator, agent_id, exc, interactive=interactive - ) + if session is not None: + await _salvage_stream_to_session(session, pre_run_items, stream, agent_id) if isinstance(exc, ProviderRefusalError): logger.warning("agent %s refused by the model provider: %s", agent_id, exc) await coordinator.set_status(agent_id, "failed", error=str(exc)) @@ -622,23 +725,7 @@ async def _run_cycle( # noqa: PLR0912, PLR0915 return None else: await _settle_run_result(coordinator, agent_id, interactive) - return stream - - -async def _handle_content_guardrail( - coordinator: AgentCoordinator, - agent_id: str, - exc: BaseException, - *, - interactive: bool, -) -> RunResultBase | None: - logger.warning("agent %s blocked by the model's content guardrail: %s", agent_id, exc) - if interactive: - await coordinator.set_status(agent_id, "waiting", error=_GUARDRAIL_PARK_ERROR) - return None - await coordinator.set_status(agent_id, "failed", error=_GUARDRAIL_PARK_ERROR) - await _notify_parent_on_terminal(coordinator, agent_id, "failed") - return None + return cast("RunResultBase | None", stream) async def _settle_run_result( diff --git a/strix/core/runner.py b/strix/core/runner.py index 01725cab..ec439866 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -377,12 +377,6 @@ async def run_strix_scan( async with coordinator._lock: root_status = coordinator.statuses.get(root_id) - root_error = coordinator.errors.get(root_id) - - root_recoverable_park = root_status == "waiting" and bool(root_error) - root_start_parked = bool( - interactive and is_resume and root_status != "running" and not root_recoverable_park - ) result = await run_agent_loop( agent=root_agent, @@ -394,7 +388,7 @@ async def run_strix_scan( agent_id=root_id, interactive=interactive, session=root_session, - start_parked=root_start_parked, + start_parked=bool(interactive and is_resume and root_status != "running"), event_sink=event_sink, hooks=hooks, ) diff --git a/strix/core/sessions.py b/strix/core/sessions.py index 2bb83982..8879d359 100644 --- a/strix/core/sessions.py +++ b/strix/core/sessions.py @@ -7,6 +7,7 @@ import logging from typing import TYPE_CHECKING, Any, cast from weakref import WeakKeyDictionary +from agents.items import ItemHelpers from agents.memory import SQLiteSession @@ -26,6 +27,18 @@ def open_agent_session(agent_id: str, path: Path) -> SQLiteSession: return SQLiteSession(session_id=agent_id, db_path=path) +async def seed_initial_input(session: Session, initial_input: Any) -> bool: + """Commit an agent's opening identity/task input before its first run cycle.""" + items = ItemHelpers.input_to_new_input_list(initial_input) + if not items: + return False + async with session_write_lock(session): + if await session.get_items(): + return False + await session.add_items(items) + return True + + _IMAGE_REJECTED_TEXT = "[image rejected by the model]" _IMAGE_ELIDED_TEXT = "[older screenshot elided to bound context memory]" _INHERITED_IMAGE_TEXT = "[screenshot omitted from inherited context]" diff --git a/strix/interface/tui/app.py b/strix/interface/tui/app.py index 14bc6cb1..21715d0c 100644 --- a/strix/interface/tui/app.py +++ b/strix/interface/tui/app.py @@ -1041,9 +1041,9 @@ class StrixTUIApp(App): # type: ignore[misc] name=names.get(agent_id, agent_id), parent_id=parent_of.get(agent_id), status=status, - error_message=error, + error_message=error or "", ) - if status in {"failed", "crashed"} and error: + if error: if agent_id not in self._error_noted_agents: self._error_noted_agents.add(agent_id) self.live_view.record_agent_error(agent_id, error) @@ -1293,6 +1293,10 @@ class StrixTUIApp(App): # type: ignore[misc] text.append("Send a message to continue", style="dim") keymap = keymap_styled([("ctrl-q", "quit")]) else: + error_msg = agent_data.get("error_message") or "" + if error_msg: + text.append(error_msg, style="red") + text.append(" \u00b7 ", style="dim") text.append("Send message to resume", style="dim") return (text, keymap, False) diff --git a/strix/interface/tui/live_view.py b/strix/interface/tui/live_view.py index 4401fb30..11a4b033 100644 --- a/strix/interface/tui/live_view.py +++ b/strix/interface/tui/live_view.py @@ -82,7 +82,7 @@ class TuiLiveView: current["parent_id"] = parent_id if status is not None: current["status"] = status - if error_message: + if error_message is not None: current["error_message"] = error_message current["updated_at"] = now diff --git a/tests/test_execution.py b/tests/test_execution.py index 8fa043f1..c1a13a72 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio import contextlib import json -from typing import Any +from typing import Any, cast from unittest.mock import MagicMock import pytest @@ -14,18 +14,19 @@ from agents.memory import SQLiteSession from agents.tool_context import ToolContext from openai.types.responses import ResponseOutputMessage, ResponseOutputRefusal -from strix.config import codex from strix.core import execution from strix.core.agents import AgentCoordinator from strix.core.execution import ( - _handle_content_guardrail, _notify_parent_on_terminal, _notify_root_on_budget_reserve, - respawn_subagents, ) +from strix.core.sessions import seed_initial_input from strix.tools.finish.tool import finish_scan +_NO_STREAM_EVENTS: list[Any] = [] + + class _StructuredRefusalStream: def __init__(self, refusal: str) -> None: self.run_loop_exception: BaseException | None = None @@ -43,8 +44,8 @@ class _StructuredRefusalStream: ] async def stream_events(self) -> Any: - if False: - yield None + for event in _NO_STREAM_EVENTS: + yield event def cancel(self, mode: str = "immediate") -> None: # noqa: ARG002 return @@ -529,44 +530,152 @@ async def test_terminal_notice_does_not_cancel_parent_stream(tmp_path: Any) -> N @pytest.mark.asyncio -async def test_guardrail_interactive_parks_agent_wakeable(tmp_path: Any) -> None: +async def test_send_queues_without_session_and_drains_on_consume(tmp_path: Any) -> None: coordinator = AgentCoordinator() await coordinator.register("root", "strix", parent_id=None) - await coordinator.register("child", "recon", parent_id="root") - exc = codex.CodexContentGuardrailError("chatgpt/gpt-5.6-sol") - result = await _handle_content_guardrail(coordinator, "child", exc, interactive=True) + assert await coordinator.send("root", {"from": "user", "content": "hello"}) is True + assert coordinator.pending_counts["root"] == 1 - assert result is None - assert coordinator.statuses["child"] == "waiting" - assert "STRIX_LLM" in coordinator.errors["child"] + session = SQLiteSession("root", tmp_path / "agents.db") + await coordinator.attach_runtime("root", session=session) - waiter = asyncio.create_task(coordinator.wait_for_message("child")) - await asyncio.sleep(0) - assert not waiter.done() - session = SQLiteSession("child", tmp_path / "agents.db") - await coordinator.attach_runtime("child", session=session) - await coordinator.send("child", {"from": "user", "content": "switched model, resume"}) - await asyncio.wait_for(waiter, timeout=1.0) + count, items = await coordinator.consume_pending("root", include_items=True) + assert count == 1 + assert items[0]["content"] == "hello" + stored = await session.get_items() + last = cast("dict[str, Any]", stored[-1]) + assert last["content"] == "hello" session.close() @pytest.mark.asyncio -async def test_guardrail_noninteractive_fails_only_blocked_agent(tmp_path: Any) -> None: +async def test_error_parked_agent_only_released_by_user_message(tmp_path: Any) -> None: coordinator = AgentCoordinator() await coordinator.register("root", "strix", parent_id=None) await coordinator.register("child", "recon", parent_id="root") - session = SQLiteSession("root", tmp_path / "agents.db") - await coordinator.attach_runtime("root", session=session) - exc = codex.CodexContentGuardrailError("chatgpt/gpt-5.6-sol") + session = SQLiteSession("child", tmp_path / "agents.db") + await coordinator.attach_runtime("child", session=session) + await coordinator.set_status("child", "crashed", error="boom") - result = await _handle_content_guardrail(coordinator, "child", exc, interactive=False) + await coordinator.send("child", {"from": "root", "content": "peer nudge"}) + waiter = asyncio.create_task(coordinator.wait_for_message("child")) + await asyncio.sleep(0.05) + assert not waiter.done() + + await coordinator.send("child", {"from": "user", "content": "wake up"}) + assert await asyncio.wait_for(waiter, timeout=1.0) is True + + count, items = await coordinator.consume_pending("child", include_items=True) + assert count == 2 + assert items[0]["content"].endswith("peer nudge") + assert items[1]["content"] == "wake up" + session.close() + + +@pytest.mark.asyncio +async def test_wait_for_message_timeout_returns_false() -> None: + coordinator = AgentCoordinator() + await coordinator.register("child", "recon", parent_id="root") + + assert await coordinator.wait_for_message("child", timeout=0.05) is False + + +@pytest.mark.asyncio +async def test_snapshot_round_trip_preserves_mailboxes() -> None: + coordinator = AgentCoordinator() + await coordinator.register("root", "strix", parent_id=None) + await coordinator.send("root", {"from": "user", "content": "queued"}) + + snap = await coordinator.snapshot() + restored = AgentCoordinator() + await restored.restore(snap) + + assert restored.pending_counts["root"] == 1 + assert restored.runtimes["root"].mailbox == [{"from": "user", "content": "queued"}] + + +@pytest.mark.asyncio +async def test_run_cycle_parked_parks_instead_of_raising( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _boom(*_args: Any, **_kwargs: Any) -> Any: + raise RuntimeError("unexpected explosion") + + monkeypatch.setattr(execution, "_run_cycle", _boom) + + coordinator = AgentCoordinator() + await coordinator.register("root", "strix", parent_id=None) + + result = await execution._run_cycle_parked( + object(), + coordinator, + "root", + input_data=[], + run_config=None, # type: ignore[arg-type] + context={}, + max_turns=5, + session=None, + event_sink=None, + hooks=None, + ) assert result is None - assert coordinator.statuses["child"] == "failed" - assert "STRIX_LLM" in coordinator.errors["child"] - assert coordinator.statuses["root"] == "running" - assert coordinator.pending_counts.get("root", 0) > 0 + assert coordinator.statuses["root"] == "failed" + assert coordinator.errors["root"] == "unexpected explosion" + + +class _SalvageStream: + def __init__(self, replay: list[dict[str, Any]]) -> None: + self._replay = replay + + def to_input_list(self) -> list[dict[str, Any]]: + return self._replay + + +@pytest.mark.asyncio +async def test_salvage_stream_to_session_preserves_full_history(tmp_path: Any) -> None: + session = SQLiteSession("child", tmp_path / "agents.db") + await session.add_items([{"role": "user", "content": "identity + task"}]) + pre_run = list(await session.get_items()) + + # A crash mid-run: the stream produced two turns the SDK never committed. + stream = _SalvageStream( + [ + {"role": "assistant", "content": "recon turn 1"}, + {"role": "assistant", "content": "recon turn 2"}, + ] + ) + await execution._salvage_stream_to_session(session, pre_run, stream, "child") + + stored = [cast("dict[str, Any]", i) for i in await session.get_items()] + assert [i["content"] for i in stored] == [ + "identity + task", + "recon turn 1", + "recon turn 2", + ] + + # A crash with nothing new to salvage leaves the session untouched. + await execution._salvage_stream_to_session( + session, list(await session.get_items()), _SalvageStream([]), "child" + ) + assert len(await session.get_items()) == 3 + session.close() + + +@pytest.mark.asyncio +async def test_seed_initial_input_persists_and_is_idempotent(tmp_path: Any) -> None: + session = SQLiteSession("child", tmp_path / "agents.db") + identity = [{"role": "user", "content": "You are agent recon (abc); do X."}] + + assert await seed_initial_input(session, identity) is True + assert len(await session.get_items()) == 1 + + # A populated session is left untouched (no duplicate identity message). + assert await seed_initial_input(session, identity) is False + assert len(await session.get_items()) == 1 + + assert await seed_initial_input(session, []) is False session.close() @@ -576,7 +685,9 @@ async def test_structured_provider_refusal_fails_interactive_agent( ) -> None: refusal = "This request was blocked under the provider's usage policy." stream = _StructuredRefusalStream(refusal) - monkeypatch.setattr(execution.Runner, "run_streamed", lambda *_args, **_kwargs: stream) + monkeypatch.setattr( + "strix.core.execution.Runner.run_streamed", lambda *_args, **_kwargs: stream + ) coordinator = AgentCoordinator() await coordinator.register("root", "strix", parent_id=None) @@ -606,7 +717,9 @@ async def test_structured_provider_refusal_fails_noninteractive_child( ) -> None: refusal = "This request was blocked under the provider's usage policy." stream = _StructuredRefusalStream(refusal) - monkeypatch.setattr(execution.Runner, "run_streamed", lambda *_args, **_kwargs: stream) + monkeypatch.setattr( + "strix.core.execution.Runner.run_streamed", lambda *_args, **_kwargs: stream + ) coordinator = AgentCoordinator() await coordinator.register("root", "strix", parent_id=None) await coordinator.register("child", "recon", parent_id="root") @@ -635,34 +748,40 @@ async def test_structured_provider_refusal_fails_noninteractive_child( @pytest.mark.asyncio -async def test_resume_revives_guardrail_parked_child_but_not_plain_waiting( +async def test_run_agent_loop_seeds_identity_before_first_cycle( tmp_path: Any, monkeypatch: pytest.MonkeyPatch ) -> None: coordinator = AgentCoordinator() - await coordinator.register("root", "strix", parent_id=None) - await coordinator.register("blocked", "recon", parent_id="root") - await coordinator.register("peer_waiter", "recon", parent_id="root") - await coordinator.set_status("blocked", "waiting", error="STRIX_LLM guardrail") - await coordinator.set_status("peer_waiter", "waiting") + await coordinator.register("child", "recon", parent_id="root") + session = SQLiteSession("child", tmp_path / "agents.db") - parked: dict[str, bool] = {} + captured: dict[str, Any] = {} - async def _fake_start_child_runner(**kwargs: Any) -> None: - parked[kwargs["child_id"]] = bool(kwargs["start_parked"]) + async def _crash_first_turn(*_args: Any, **kwargs: Any) -> Any: + captured["input_data"] = kwargs.get("input_data") + captured["items_at_start"] = await session.get_items() + raise RuntimeError("first-turn crash") - monkeypatch.setattr(execution, "_start_child_runner", _fake_start_child_runner) + monkeypatch.setattr(execution, "_run_cycle", _crash_first_turn) - await respawn_subagents( - coordinator=coordinator, - factory=lambda **_kwargs: object(), - agents_db_path=tmp_path / "agents.db", - sessions_to_close=[], - run_config=MagicMock(), - max_turns=10, - interactive=True, - parent_ctx={"agent_id": "root", "parent_id": None}, - root_id="root", - ) + identity = [{"role": "user", "content": "You are agent recon (abc); maintain your identity."}] + with pytest.raises(RuntimeError, match="first-turn crash"): + await execution.run_agent_loop( + agent=object(), + initial_input=identity, + run_config=None, # type: ignore[arg-type] + context={"agent_id": "child", "parent_id": "root"}, + max_turns=5, + coordinator=coordinator, + agent_id="child", + interactive=False, + session=session, + ) - assert parked["blocked"] is False - assert parked["peer_waiter"] is True + # The first cycle ran with an empty input against the pre-seeded session. + assert captured["input_data"] == [] + assert captured["items_at_start"] + # The identity/task survives the first-turn crash, so a revival can resume it. + stored = await session.get_items() + assert any("recon" in str(cast("dict[str, Any]", i).get("content", "")) for i in stored) + session.close() diff --git a/tests/test_execution_transient_retry.py b/tests/test_execution_transient_retry.py index 889eb96f..d81e4e70 100644 --- a/tests/test_execution_transient_retry.py +++ b/tests/test_execution_transient_retry.py @@ -15,6 +15,7 @@ from openai import ( RateLimitError, ) +from strix.config import codex from strix.core import execution from strix.core.agents import AgentCoordinator @@ -55,11 +56,27 @@ def test_server_errors_are_transient() -> None: assert execution._is_transient_model_error(_status_error(status)) is True -def test_rate_limit_is_not_retried_here() -> None: +def test_rate_limit_is_retried() -> None: rate_limited = RateLimitError( "slow down", response=httpx.Response(429, request=_request()), body=None ) - assert execution._is_transient_model_error(rate_limited) is False + assert execution._is_transient_model_error(rate_limited) is True + + +def test_dns_and_connection_errors_are_transient() -> None: + assert execution._is_transient_model_error(OSError("nodename nor servname provided")) is True + assert execution._is_transient_model_error(ConnectionError("reset")) is True + assert execution._is_transient_model_error(TimeoutError("timed out")) is True + + +def test_content_guardrail_is_not_retried() -> None: + guardrail = APIError( + "This content was flagged for possible cybersecurity risk", + _request(), + body=None, + ) + assert codex.is_content_guardrail_error(guardrail) is True + assert execution._is_transient_model_error(guardrail) is False def test_client_errors_are_not_transient() -> None: From dc7cc50f8056498161d6c9ff7a5854039f054c12 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:01:34 +0300 Subject: [PATCH 11/57] docs(prompt): teach agents to recognize Caido proxy error pages instead of chasing them (#955) * docs(prompt): teach agents to recognize Caido proxy error pages * docs(prompt): tighten Caido proxy error page section --------- Co-authored-by: Ahmed Allam --- strix/agents/prompts/system_prompt.jinja | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja index bf2549ad..f7c3dcb9 100644 --- a/strix/agents/prompts/system_prompt.jinja +++ b/strix/agents/prompts/system_prompt.jinja @@ -446,8 +446,19 @@ PROXY & INTERCEPTION: - Caido CLI - Modern web proxy (already running). Use the proxy tools directly, or import `caido_api` from sandbox Python scripts. - HTTPQL filters (for `list_requests`): quote string values, leave integers unquoted (`resp.code.eq:200`, not `"200"`); combine terms with `AND`/`OR` (there is no `NOT` — use the negated operator `ne`/`ncont`/`nregex`). Numeric fields (`resp.code`, `req.port`) use `eq`/`ne`/`gt`/`gte`/`lt`/`lte`; text fields (`req.host`, `req.path`, `req.method`, `req.raw`) use `cont`/`ncont`/`eq`/`regex`. Example: `resp.code.gte:200 AND resp.code.lt:300 AND req.host.cont:"api"`. -- NOTE: If you are seeing proxy errors when sending requests, it usually means you are not sending requests to a correct url/host/port. -- Ignore Caido proxy-generated 50x HTML error pages; these are proxy issues (might happen when requesting a wrong host or SSL/TLS issues, etc). + +CAIDO PROXY ERROR PAGES — NOT RESPONSES FROM THE TARGET: +Everything is proxied through Caido, so an unreachable target makes the *proxy* answer: a ~9KB +`Caido` HTML page under 502/500, which curl/python/browser print as if it were the +target's content. The request never reached a server. It also appears in `list_requests` with no +response at all (`resp` null), unlike a real 502. +- Don't dump it; extract the cause with `curl -s ... | grep -A8 'c-title"'`. +- The `c-details` cause says what to fix: "Failed to query DNS" — host doesn't resolve, check + `dig +short `, then correct or drop it; "Connection refused" — nothing on that port, check + `nc -z -v `; "TLS handshake"/"wrong version number" — scheme/port mismatch, flip + http/https; timeout — filtered or unreachable from the sandbox. +- NEVER treat these as target behavior: not a finding, not evidence, not a WAF, not a server + error. Fix the url/host/port/scheme and retry, or move on — do not keep re-requesting a dead host. PROGRAMMING: - Python 3, uv, Node.js/npm From f6f9469e00618bd4b6651d215216a627926b31ef Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Sat, 1 Aug 2026 19:16:53 +0000 Subject: [PATCH 12/57] fix(core): stop interactive runs stalling on a missing tool call Interactive turns ended by plain text left the agent parked in 'waiting' forever. Require an explicit lifecycle tool in both modes and nudge a text-only turn back into a tool call, bounded by a recovery limit. --- strix/agents/prompts/system_prompt.jinja | 23 ++- strix/core/execution.py | 186 +++++++++++++---------- strix/tools/agents_graph/tools.py | 14 +- tests/test_execution.py | 146 ++++++++++++++++++ 4 files changed, 276 insertions(+), 93 deletions(-) diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja index f7c3dcb9..bb5924fb 100644 --- a/strix/agents/prompts/system_prompt.jinja +++ b/strix/agents/prompts/system_prompt.jinja @@ -31,19 +31,16 @@ INTER-AGENT MESSAGES: {% if interactive %} INTERACTIVE BEHAVIOR: -- You are in an interactive conversation with a user -- CRITICAL: A message WITHOUT a tool call IMMEDIATELY STOPS your entire execution and waits for user input. This is a HARD SYSTEM CONSTRAINT, not a suggestion. - - Statements like "Planning the assessment..." or "I'll now scan..." or "Starting with..." WITHOUT a tool call will HALT YOUR WORK COMPLETELY. The system interprets no-tool-call as "I'm done, waiting for the user." - - If you want to plan, call the think tool. If you want to act, call the appropriate tool. There is NO valid reason to output text without a tool call while working on a task. - - The ONLY time you may send a message without a tool call is when you are genuinely DONE and presenting final results, or when you NEED the user to answer a question before continuing. -- EVERY message while working MUST contain exactly one tool call — this is what keeps execution moving. No tool call = execution stops. -- You may include brief explanatory text BEFORE the tool call -- Respond naturally when the user asks questions or gives instructions -- For simple conversation, acknowledgements, or direct questions that you can answer from current context, reply in plain text and stop. Do NOT call think just to prepare wording. -- If you use a tool to answer a user question (for example list_todos, view_agent_graph, or a file read), then after the tool result arrives, provide the answer in plain text and stop unless the user explicitly asked you to continue working. -- Never loop through think or other tools just to prepare, polish, confirm, or announce a final answer. Once you know the answer, say it. -- NEVER send empty messages — if you have nothing to do or say, call the wait_for_message tool -- If you catch yourself about to describe multiple steps without a tool call, STOP and call the think tool instead +- You are in an interactive conversation with a user. +- HOW EXECUTION ENDS: your turn ends ONLY when you make an explicit lifecycle tool call. Plain text NEVER ends your turn and NEVER hands control to the user — text is shown to the user, and then execution continues. + - To hand control back to the user (you answered them, or you need their input before continuing), call the wait_for_message tool. This is the ONLY sanctioned way to yield to the user; it parks you until the user's next message arrives. + - To end the whole engagement, call the lifecycle tool: finish_scan (root) or agent_finish (subagent). + - A turn that ends with plain text and no tool call does NOT stop you: the system nudges you to continue and will re-run you. Do not rely on going silent to pause — it will not pause you. +- Answering a user question: put your answer in text, then call wait_for_message in the SAME turn (the text is delivered to the user and you park for their reply). Do not answer and then fall silent — that just triggers a continuation nudge. +- You may include brief explanatory text before a tool call. +- Respond naturally when the user asks questions or gives instructions. +- While actively working on a task, every turn should carry exactly one tool call — use think to plan, the appropriate tool to act, and wait_for_message only when you genuinely need the user. +- Never loop through think or other tools just to prepare, polish, confirm, or announce an answer. Once you know the answer, say it (then wait_for_message). {% else %} AUTONOMOUS BEHAVIOR: - Work autonomously by default diff --git a/strix/core/execution.py b/strix/core/execution.py index 762fcd3f..b16041bb 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -208,22 +208,8 @@ async def run_agent_loop( await coordinator.send(agent_id, _reserve_notice()) if not (start_parked and interactive): - if interactive: - with contextlib.suppress(BudgetPausedError): - result = await _run_cycle_parked( - agent, - coordinator, - agent_id, - input_data=first_cycle_input, - run_config=run_config, - context=context, - max_turns=max_turns, - session=session, - event_sink=event_sink, - hooks=hooks, - ) - else: - result = await _run_noninteractive_until_lifecycle( + with contextlib.suppress(BudgetPausedError): + result = await _run_until_lifecycle( agent, coordinator, agent_id, @@ -232,6 +218,7 @@ async def run_agent_loop( context=context, max_turns=max_turns, session=session, + interactive=interactive, event_sink=event_sink, hooks=hooks, ) @@ -268,15 +255,16 @@ async def run_agent_loop( await coordinator.consume_pending(agent_id) with contextlib.suppress(BudgetPausedError): - result = await _run_cycle_parked( + result = await _run_until_lifecycle( agent, coordinator, agent_id, - input_data=[], + initial_input=[], run_config=run_config, context=context, max_turns=max_turns, session=session, + interactive=True, event_sink=event_sink, hooks=hooks, ) @@ -427,7 +415,10 @@ async def respawn_subagents( await coordinator.set_status(child_id, "crashed") -async def _run_noninteractive_until_lifecycle( +_INTERACTIVE_TOOL_RECOVERY_LIMIT = 3 + + +async def _run_until_lifecycle( agent: Any, coordinator: AgentCoordinator, agent_id: str, @@ -437,14 +428,21 @@ async def _run_noninteractive_until_lifecycle( context: dict[str, Any], max_turns: int, session: Session | None, + interactive: bool, event_sink: StreamEventSink | None, hooks: RunHooks[dict[str, Any]] | None, ) -> RunResultBase | None: - """Non-chat mode keeps running until finish_scan / agent_finish settles status.""" + """Drive an agent until an explicit lifecycle tool settles its status. + + A turn that ends without ``finish_scan``, ``agent_finish``, or + ``wait_for_message`` leaves the agent ``running``: plain text never + terminates a run and never yields to the user. Such a turn is nudged back + into a tool call, bounded by a recovery limit. + """ result: RunResultBase | None = None input_data: Any = initial_input - invalid_final_outputs = 0 - invalid_final_output_limit = max(1, max_turns) + recoveries = 0 + recovery_limit = _INTERACTIVE_TOOL_RECOVERY_LIMIT if interactive else max(1, max_turns) while True: if coordinator.budget_stopped: @@ -455,50 +453,89 @@ async def _run_noninteractive_until_lifecycle( await coordinator.set_status(agent_id, "stopped") raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve") - result = await _run_cycle( - agent, - coordinator, - agent_id, - input_data=input_data, - run_config=run_config, - context=context, - max_turns=max_turns, - session=session, - interactive=False, - event_sink=event_sink, - hooks=hooks, - ) + if interactive: + result = await _run_cycle_parked( + agent, + coordinator, + agent_id, + input_data=input_data, + run_config=run_config, + context=context, + max_turns=max_turns, + session=session, + event_sink=event_sink, + hooks=hooks, + ) + else: + result = await _run_cycle( + agent, + coordinator, + agent_id, + input_data=input_data, + run_config=run_config, + context=context, + max_turns=max_turns, + session=session, + interactive=False, + event_sink=event_sink, + hooks=hooks, + ) status = await _agent_status(coordinator, agent_id) if status != "running": return result - invalid_final_outputs += 1 + recoveries += 1 logger.warning( - "agent %s produced non-lifecycle final output in non-interactive mode; " + "agent %s ended a turn without a lifecycle tool call (interactive=%s); " "forcing tool continuation (%d/%d): %s", agent_id, - invalid_final_outputs, - invalid_final_output_limit, + interactive, + recoveries, + recovery_limit, _final_output_preview(result), ) - if invalid_final_outputs >= invalid_final_output_limit: - await coordinator.set_status(agent_id, "crashed") - await _notify_parent_on_terminal(coordinator, agent_id, "crashed") - raise MaxTurnsExceeded( - "Agent exhausted non-interactive recovery attempts without calling " - "finish_scan or agent_finish." - ) + if recoveries >= recovery_limit: + return await _exhausted_recovery(coordinator, agent_id, result, interactive=interactive) - input_data = await _append_noninteractive_tool_required_message( + input_data = await _append_tool_required_message( session=session, context=context, - attempt=invalid_final_outputs, - limit=invalid_final_output_limit, + attempt=recoveries, + limit=recovery_limit, + interactive=interactive, ) +async def _exhausted_recovery( + coordinator: AgentCoordinator, + agent_id: str, + result: RunResultBase | None, + *, + interactive: bool, +) -> RunResultBase | None: + """Settle an agent that never recovered into a tool call. + + Interactive runs park instead of dying: a human is present, so the scan + stays resumable by sending another message. Autonomous runs have nobody to + resume them, so they fail loudly. + """ + if not interactive: + await coordinator.set_status(agent_id, "crashed") + await _notify_parent_on_terminal(coordinator, agent_id, "crashed") + raise MaxTurnsExceeded( + "Agent exhausted recovery attempts without calling finish_scan or agent_finish." + ) + + logger.warning( + "agent %s exhausted tool-call recovery attempts; parking until a message arrives", + agent_id, + ) + await coordinator.set_status(agent_id, "waiting") + return result + + _WAITING_AUTO_RESUME_TIMEOUT_S = 600.0 @@ -724,27 +761,9 @@ async def _run_cycle( # noqa: PLR0912, PLR0915 await _notify_parent_on_terminal(coordinator, agent_id, status) return None else: - await _settle_run_result(coordinator, agent_id, interactive) return cast("RunResultBase | None", stream) -async def _settle_run_result( - coordinator: AgentCoordinator, - agent_id: str, - interactive: bool, -) -> None: - async with coordinator._lock: - current_status = coordinator.statuses.get(agent_id) - - if current_status != "running": - return - - if not interactive: - return - - await coordinator.set_status(agent_id, "waiting") - - async def _agent_status(coordinator: AgentCoordinator, agent_id: str) -> Status | None: async with coordinator._lock: return coordinator.statuses.get(agent_id) @@ -760,23 +779,36 @@ def _final_output_preview(result: RunResultBase | None) -> str: return text[:300] -async def _append_noninteractive_tool_required_message( +async def _append_tool_required_message( *, session: Session | None, context: dict[str, Any], attempt: int, limit: int, + interactive: bool, ) -> list[dict[str, str]]: finish_tool = "finish_scan" if context.get("parent_id") is None else "agent_finish" - message = ( - "Your previous response ended the autonomous Strix run without a lifecycle tool call. " - "That is invalid in non-interactive mode; plain text final answers are ignored. " - "Continue immediately and call exactly one tool. " - f"If your work is complete, call {finish_tool}. " - "If you are blocked waiting for another agent, call wait_for_message. " - "Otherwise use the appropriate execution or planning tool. " - f"This is recovery attempt {attempt}/{limit}." - ) + if interactive: + message = ( + "Your previous message ended a turn without a tool call. Plain text never ends " + "execution and never hands control to the user: it is shown to the user, and the " + "run continues. Continue immediately and call exactly one tool. " + "If you have finished responding and want the user's next message, call " + "wait_for_message. " + f"If the whole engagement is complete, call {finish_tool}. " + "Otherwise use the appropriate execution or planning tool. " + f"This is recovery attempt {attempt}/{limit}." + ) + else: + message = ( + "Your previous response ended the autonomous Strix run without a lifecycle tool " + "call. That is invalid in non-interactive mode; plain text final answers are " + "ignored. Continue immediately and call exactly one tool. " + f"If your work is complete, call {finish_tool}. " + "If you are blocked waiting for another agent, call wait_for_message. " + "Otherwise use the appropriate execution or planning tool. " + f"This is recovery attempt {attempt}/{limit}." + ) item = {"role": "user", "content": message} if session is None: return [item] diff --git a/strix/tools/agents_graph/tools.py b/strix/tools/agents_graph/tools.py index 7f937400..b5d1af9d 100644 --- a/strix/tools/agents_graph/tools.py +++ b/strix/tools/agents_graph/tools.py @@ -232,11 +232,19 @@ async def wait_for_message( # noqa: PLR0911 message arrives, so pick a ``timeout_seconds`` proportional to the work you're awaiting. + In an interactive/chat session this is also the ONLY sanctioned way + to hand control back to the user: plain text does not end your turn + or yield to the user, so after you answer the user (or when you need + their input before continuing) call this to park until their next + message arrives. + **Critical caveats:** - - **Never** call this if you finished your own task and have **no** - child agents running — that's a permanent stall. Call - ``finish_scan`` (root) or ``agent_finish`` (subagent) instead. + - In an autonomous (non-interactive) run, **never** call this if you + finished your own task and have **no** child agents running — that's a + permanent stall. Call ``finish_scan`` (root) or ``agent_finish`` + (subagent) instead. (In an interactive session there is always a user + who can message you, so parking to await the user is expected.) - If you're waiting on an agent that **isn't your child**, message it first asking it to ping you when done — otherwise it has no reason to send to your inbox and you'll wait the full timeout. diff --git a/tests/test_execution.py b/tests/test_execution.py index c1a13a72..7fc73782 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -9,6 +9,7 @@ from typing import Any, cast from unittest.mock import MagicMock import pytest +from agents.exceptions import MaxTurnsExceeded from agents.items import MessageOutputItem from agents.memory import SQLiteSession from agents.tool_context import ToolContext @@ -785,3 +786,148 @@ async def test_run_agent_loop_seeds_identity_before_first_cycle( stored = await session.get_items() assert any("recon" in str(cast("dict[str, Any]", i).get("content", "")) for i in stored) session.close() + + +def _scripted_cycle( + coordinator: AgentCoordinator, + agent_id: str, + statuses: list[str], + calls: list[Any], +) -> Any: + """Fake run cycle that leaves ``agent_id`` in a scripted status per call.""" + + async def _cycle(*_args: Any, **kwargs: Any) -> Any: + calls.append(kwargs.get("input_data")) + status = statuses[min(len(calls) - 1, len(statuses) - 1)] + await coordinator.set_status(agent_id, status) + return MagicMock(final_output="plain text, no tool call") + + return _cycle + + +async def _drive( + coordinator: AgentCoordinator, + agent_id: str, + *, + interactive: bool, + max_turns: int = 5, +) -> Any: + return await execution._run_until_lifecycle( + MagicMock(), + coordinator, + agent_id, + initial_input=[], + run_config=MagicMock(), + context={"agent_id": agent_id, "parent_id": None}, + max_turns=max_turns, + session=None, + interactive=interactive, + event_sink=None, + hooks=None, + ) + + +@pytest.mark.asyncio +async def test_interactive_text_only_turn_is_nudged_instead_of_parking( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A no-tool-call turn must not silently hand control back to the user.""" + coordinator = AgentCoordinator() + await coordinator.register("root", "strix", parent_id=None) + calls: list[Any] = [] + monkeypatch.setattr( + execution, + "_run_cycle_parked", + _scripted_cycle(coordinator, "root", ["running", "completed"], calls), + ) + + await _drive(coordinator, "root", interactive=True) + + assert len(calls) == 2 + # The retry carries an explicit "call a tool" nudge rather than empty input. + nudge = calls[1][0]["content"] + assert "without a tool call" in nudge + assert "wait_for_message" in nudge + assert coordinator.statuses["root"] == "completed" + + +@pytest.mark.asyncio +async def test_interactive_wait_for_message_parks_without_a_nudge( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``waiting`` is now only reachable by an explicit wait_for_message call.""" + coordinator = AgentCoordinator() + await coordinator.register("root", "strix", parent_id=None) + calls: list[Any] = [] + monkeypatch.setattr( + execution, + "_run_cycle_parked", + _scripted_cycle(coordinator, "root", ["waiting"], calls), + ) + + await _drive(coordinator, "root", interactive=True) + + assert len(calls) == 1 + assert coordinator.statuses["root"] == "waiting" + + +@pytest.mark.asyncio +async def test_interactive_recovery_exhaustion_parks_instead_of_crashing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A human can resume an interactive scan, so exhaustion parks rather than dies.""" + coordinator = AgentCoordinator() + await coordinator.register("root", "strix", parent_id=None) + calls: list[Any] = [] + monkeypatch.setattr( + execution, + "_run_cycle_parked", + _scripted_cycle(coordinator, "root", ["running"], calls), + ) + + await _drive(coordinator, "root", interactive=True) + + assert len(calls) == execution._INTERACTIVE_TOOL_RECOVERY_LIMIT + assert coordinator.statuses["root"] == "waiting" + + +@pytest.mark.asyncio +async def test_noninteractive_recovery_exhaustion_crashes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No user is present to resume an autonomous run, so it still fails loudly.""" + coordinator = AgentCoordinator() + await coordinator.register("root", "strix", parent_id=None) + calls: list[Any] = [] + monkeypatch.setattr( + execution, + "_run_cycle", + _scripted_cycle(coordinator, "root", ["running"], calls), + ) + + with pytest.raises(MaxTurnsExceeded): + await _drive(coordinator, "root", interactive=False, max_turns=2) + + assert len(calls) == 2 + assert coordinator.statuses["root"] == "crashed" + + +@pytest.mark.asyncio +async def test_tool_required_message_is_persisted_to_the_session(tmp_path: Any) -> None: + session = SQLiteSession("root", tmp_path / "agents.db") + + assert ( + await execution._append_tool_required_message( + session=session, + context={"parent_id": None}, + attempt=1, + limit=3, + interactive=True, + ) + == [] + ) + + stored = [cast("dict[str, Any]", i) for i in await session.get_items()] + assert "finish_scan" in stored[0]["content"] + assert "wait_for_message" in stored[0]["content"] + session.close() From 6eec34df2492e0fd1045613717d92a948b639af1 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Sat, 1 Aug 2026 19:36:20 +0000 Subject: [PATCH 13/57] fix(core): persist the tool-call recovery counter across resumes An exhausted agent parked in 'waiting' got a fresh nudge budget on every 600s auto-resume, so a wedged agent could nudge-park-nudge indefinitely. Track the count on the coordinator, snapshot it, and reset it only on real input or an explicit lifecycle tool. --- strix/core/agents.py | 22 +++++++++++++++++ strix/core/execution.py | 10 +++++--- tests/test_execution.py | 53 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/strix/core/agents.py b/strix/core/agents.py index 3f20629d..d03636e1 100644 --- a/strix/core/agents.py +++ b/strix/core/agents.py @@ -46,6 +46,7 @@ class AgentCoordinator: self.metadata: dict[str, dict[str, Any]] = {} self.pending_counts: dict[str, int] = {} self.errors: dict[str, str] = {} + self.recovery_counts: dict[str, int] = {} self.runtimes: dict[str, AgentRuntime] = {} self._lock = asyncio.Lock() self._snapshot_path: Path | None = None @@ -187,6 +188,25 @@ class AgentCoordinator: async def park_waiting(self, agent_id: str) -> None: await self.set_status(agent_id, "waiting") + async def record_recovery(self, agent_id: str) -> int: + """Count a turn that ended without a lifecycle tool call; return the new total. + + Persisted so a resumed agent cannot earn a fresh nudge budget on every + auto-resume and loop forever. + """ + async with self._lock: + count = self.recovery_counts.get(agent_id, 0) + 1 + self.recovery_counts[agent_id] = count + await self._maybe_snapshot() + return count + + async def reset_recovery(self, agent_id: str) -> None: + """Clear the nudge budget after real progress (new message or a lifecycle tool).""" + async with self._lock: + if self.recovery_counts.pop(agent_id, None) is None: + return + await self._maybe_snapshot() + async def set_status( self, agent_id: str, status: Status | str, *, error: str | None = None ) -> None: @@ -397,6 +417,7 @@ class AgentCoordinator: "names": dict(self.names), "metadata": {aid: dict(md) for aid, md in self.metadata.items()}, "pending_counts": dict(self.pending_counts), + "recovery_counts": dict(self.recovery_counts), "mailboxes": { aid: [dict(m) for m in runtime.mailbox] for aid, runtime in self.runtimes.items() @@ -416,6 +437,7 @@ class AgentCoordinator: self.metadata = {aid: dict(md) for aid, md in snap.get("metadata", {}).items()} self.pending_counts = dict(snap.get("pending_counts", {})) self.errors = dict(snap.get("errors", {})) + self.recovery_counts = dict(snap.get("recovery_counts", {})) mailboxes = snap.get("mailboxes", {}) if isinstance(mailboxes, dict): for aid, msgs in mailboxes.items(): diff --git a/strix/core/execution.py b/strix/core/execution.py index b16041bb..d9f8c0a9 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -241,7 +241,11 @@ async def run_agent_loop( await coordinator.set_status(agent_id, "stopped") raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve") - if not woke: + if woke: + # Real input is real progress, so the nudge budget starts over. A bare + # auto-resume is not: it must not hand a wedged agent a fresh budget. + await coordinator.reset_recovery(agent_id) + else: logger.info("agent %s reached its waiting timeout; auto-resuming", agent_id) await coordinator.send( agent_id, @@ -441,7 +445,6 @@ async def _run_until_lifecycle( """ result: RunResultBase | None = None input_data: Any = initial_input - recoveries = 0 recovery_limit = _INTERACTIVE_TOOL_RECOVERY_LIMIT if interactive else max(1, max_turns) while True: @@ -483,9 +486,10 @@ async def _run_until_lifecycle( status = await _agent_status(coordinator, agent_id) if status != "running": + await coordinator.reset_recovery(agent_id) return result - recoveries += 1 + recoveries = await coordinator.record_recovery(agent_id) logger.warning( "agent %s ended a turn without a lifecycle tool call (interactive=%s); " "forcing tool continuation (%d/%d): %s", diff --git a/tests/test_execution.py b/tests/test_execution.py index 7fc73782..3b2df11c 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -931,3 +931,56 @@ async def test_tool_required_message_is_persisted_to_the_session(tmp_path: Any) assert "finish_scan" in stored[0]["content"] assert "wait_for_message" in stored[0]["content"] session.close() + + +@pytest.mark.asyncio +async def test_recovery_count_survives_a_snapshot_round_trip( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A resumed agent must not earn a fresh nudge budget and loop forever.""" + coordinator = AgentCoordinator() + await coordinator.register("root", "strix", parent_id=None) + calls: list[Any] = [] + monkeypatch.setattr( + execution, + "_run_cycle_parked", + _scripted_cycle(coordinator, "root", ["running"], calls), + ) + + await _drive(coordinator, "root", interactive=True) + assert coordinator.recovery_counts["root"] == execution._INTERACTIVE_TOOL_RECOVERY_LIMIT + + restored = AgentCoordinator() + await restored.restore(await coordinator.snapshot()) + assert restored.recovery_counts["root"] == execution._INTERACTIVE_TOOL_RECOVERY_LIMIT + + # The restored agent is already at its cap, so it parks after a single + # further text-only cycle instead of starting the whole budget over. + resumed_calls: list[Any] = [] + monkeypatch.setattr( + execution, + "_run_cycle_parked", + _scripted_cycle(restored, "root", ["running"], resumed_calls), + ) + await _drive(restored, "root", interactive=True) + + assert len(resumed_calls) == 1 + assert restored.statuses["root"] == "waiting" + + +@pytest.mark.asyncio +async def test_recovery_count_is_cleared_by_a_lifecycle_tool( + monkeypatch: pytest.MonkeyPatch, +) -> None: + coordinator = AgentCoordinator() + await coordinator.register("root", "strix", parent_id=None) + calls: list[Any] = [] + monkeypatch.setattr( + execution, + "_run_cycle_parked", + _scripted_cycle(coordinator, "root", ["running", "completed"], calls), + ) + + await _drive(coordinator, "root", interactive=True) + + assert "root" not in coordinator.recovery_counts From 49057f267f2130a39eadb31668b469180a7d55a2 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Sat, 1 Aug 2026 21:25:09 +0000 Subject: [PATCH 14/57] fix(tools): halve the wait_for_message ceiling to 300s A mutual wait between two agents resolves only when both hit their cap, so the ceiling is the worst-case idle burn. Name the constants instead of repeating the literal, and align the interactive auto-resume timeout. --- strix/core/execution.py | 2 +- strix/tools/agents_graph/tools.py | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/strix/core/execution.py b/strix/core/execution.py index d9f8c0a9..5862adb2 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -540,7 +540,7 @@ async def _exhausted_recovery( return result -_WAITING_AUTO_RESUME_TIMEOUT_S = 600.0 +_WAITING_AUTO_RESUME_TIMEOUT_S = 300.0 async def _plain_waiting_timeout( diff --git a/strix/tools/agents_graph/tools.py b/strix/tools/agents_graph/tools.py index b5d1af9d..ec1e3912 100644 --- a/strix/tools/agents_graph/tools.py +++ b/strix/tools/agents_graph/tools.py @@ -218,11 +218,18 @@ def _session_items_payload(items: list[Any]) -> list[dict[str, Any]]: return payload -@function_tool(timeout=601) +_WAIT_DEFAULT_TIMEOUT_S = 300 +# Enforced by the SDK around the whole tool call, so it caps an oversized +# ``timeout_seconds`` the model asks for. One second of headroom lets the +# tool's own timeout fire first and return a clean result. +_WAIT_HARD_CEILING_S = _WAIT_DEFAULT_TIMEOUT_S + 1 + + +@function_tool(timeout=_WAIT_HARD_CEILING_S) async def wait_for_message( # noqa: PLR0911 ctx: RunContextWrapper, reason: str = "Waiting for messages from other agents", - timeout_seconds: int = 600, + timeout_seconds: int = _WAIT_DEFAULT_TIMEOUT_S, ) -> str: """Pause this agent until a message lands in its inbox (or timeout). @@ -255,7 +262,8 @@ async def wait_for_message( # noqa: PLR0911 reason: One-line note shown in graph snapshots while you're waiting (helps a human or sibling agent debug who's stuck on what). - timeout_seconds: Max seconds to wait (default 600). This is only + timeout_seconds: Max seconds to wait (default 300, and values above + that are cut short by a hard ceiling). This is only a cap — the tool returns the INSTANT a message arrives, so a larger value never makes you wait longer when the reply does come. Right-size it to what you're waiting on: a short wait From 8f1bb64d167fefb4eab561fe01f1c42cb143c7c3 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Sat, 1 Aug 2026 21:30:44 +0000 Subject: [PATCH 15/57] fix(core): tell the parent when an interactive subagent parks Parking is self-service only for the root, which the user is watching. A parked child owes its parent a report it can no longer send, so the parent would wait out its full timeout for nothing. --- strix/core/execution.py | 34 +++++++++++++++++++++++++++++ tests/test_execution.py | 48 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/strix/core/execution.py b/strix/core/execution.py index 5862adb2..97fc1c94 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -537,6 +537,10 @@ async def _exhausted_recovery( agent_id, ) await coordinator.set_status(agent_id, "waiting") + # The user talks to the root, so parking is only self-service there. A parked + # subagent owes its parent a report it can no longer send, and the parent would + # otherwise wait out its full timeout for a message that is never coming. + await _notify_parent_on_stall(coordinator, agent_id) return result @@ -839,6 +843,36 @@ _TERMINAL_NOTICE = { } +_STALL_NOTICE = ( + "[Agent stalled] {name} ({agent_id}) kept ending turns without a tool call and is " + "parked until it receives a message. It will not send a completion report on its " + "own: either message it with a concrete next step to unblock it, or stop waiting on " + "it and account for its unfinished subtask." +) + + +async def _notify_parent_on_stall( + coordinator: AgentCoordinator, + agent_id: str, +) -> None: + """Tell the parent that a child parked mid-task, so it stops waiting blindly.""" + async with coordinator._lock: + parent = coordinator.parent_of.get(agent_id) + name = coordinator.names.get(agent_id, agent_id) + if parent is None: + return + await coordinator.send( + parent, + { + "from": agent_id, + "type": "stalled", + "priority": "high", + "content": _STALL_NOTICE.format(name=name, agent_id=agent_id), + }, + interrupt=False, + ) + + async def _notify_parent_on_terminal( coordinator: AgentCoordinator, agent_id: str, diff --git a/tests/test_execution.py b/tests/test_execution.py index 3b2df11c..8adcaa0f 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -891,6 +891,54 @@ async def test_interactive_recovery_exhaustion_parks_instead_of_crashing( assert coordinator.statuses["root"] == "waiting" +@pytest.mark.asyncio +async def test_interactive_subagent_exhaustion_tells_its_parent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The user only talks to the root, so a parked child must report up. + + Otherwise a parent blocked in wait_for_message burns its whole timeout + waiting for a completion report the child can no longer send. + """ + coordinator = AgentCoordinator() + await coordinator.register("root", "strix", parent_id=None) + await coordinator.register("child", "recon", parent_id="root") + calls: list[Any] = [] + monkeypatch.setattr( + execution, + "_run_cycle_parked", + _scripted_cycle(coordinator, "child", ["running"], calls), + ) + + await _drive(coordinator, "child", interactive=True) + + assert coordinator.statuses["child"] == "waiting" + pending, items = await coordinator.consume_pending("root", include_items=True) + assert pending == 1 + notice = str(items[0]) + assert "child" in notice + assert "parked" in notice + + +@pytest.mark.asyncio +async def test_interactive_root_exhaustion_notifies_nobody( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A parked root is self-service: the user is already watching it.""" + coordinator = AgentCoordinator() + await coordinator.register("root", "strix", parent_id=None) + monkeypatch.setattr( + execution, + "_run_cycle_parked", + _scripted_cycle(coordinator, "root", ["running"], []), + ) + + await _drive(coordinator, "root", interactive=True) + + pending, _ = await coordinator.consume_pending("root") + assert pending == 0 + + @pytest.mark.asyncio async def test_noninteractive_recovery_exhaustion_crashes( monkeypatch: pytest.MonkeyPatch, From 742f382836f03a90921b96059052f376b104fffb Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Sat, 1 Aug 2026 21:36:54 +0000 Subject: [PATCH 16/57] docs(core): correct the rationale for notifying a stalled child's parent The user can message any agent from the TUI, not only the root, so the justification is that the parent is an agent with no other way to learn the child parked - not that the child has no human resumer. --- strix/core/execution.py | 6 +++--- tests/test_execution.py | 9 +++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/strix/core/execution.py b/strix/core/execution.py index 97fc1c94..5b27a4cc 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -537,9 +537,9 @@ async def _exhausted_recovery( agent_id, ) await coordinator.set_status(agent_id, "waiting") - # The user talks to the root, so parking is only self-service there. A parked - # subagent owes its parent a report it can no longer send, and the parent would - # otherwise wait out its full timeout for a message that is never coming. + # A parked child owes its parent a completion report it can no longer send. The + # parent is an agent, not a watching human, so nothing else tells it to stop + # waiting and it burns its full timeout on a message that is never coming. await _notify_parent_on_stall(coordinator, agent_id) return result diff --git a/tests/test_execution.py b/tests/test_execution.py index 8adcaa0f..491feaec 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -895,10 +895,11 @@ async def test_interactive_recovery_exhaustion_parks_instead_of_crashing( async def test_interactive_subagent_exhaustion_tells_its_parent( monkeypatch: pytest.MonkeyPatch, ) -> None: - """The user only talks to the root, so a parked child must report up. + """A parked child must report up so its parent stops waiting on it. - Otherwise a parent blocked in wait_for_message burns its whole timeout - waiting for a completion report the child can no longer send. + The parent is an agent, not a watching human, so a parent blocked in + wait_for_message otherwise burns its whole timeout on a completion + report the child can no longer send. """ coordinator = AgentCoordinator() await coordinator.register("root", "strix", parent_id=None) @@ -924,7 +925,7 @@ async def test_interactive_subagent_exhaustion_tells_its_parent( async def test_interactive_root_exhaustion_notifies_nobody( monkeypatch: pytest.MonkeyPatch, ) -> None: - """A parked root is self-service: the user is already watching it.""" + """The root has no parent to report to, so parking stays silent.""" coordinator = AgentCoordinator() await coordinator.register("root", "strix", parent_id=None) monkeypatch.setattr( From 1c1fa4996134daa4c857902df61933932b6a57e1 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Sat, 1 Aug 2026 22:13:07 +0000 Subject: [PATCH 17/57] refactor(tools): split wait_for_message into respond_to_user + wait_for_agents One tool was doing three jobs (wait on the user, wait on other agents, and - wrongly - wait for a long-running command), so the driver had to guess which one an agent meant and used parent_id as the proxy: the root waits for a human, everyone else waits for agents. That proxy is wrong, since the user can message any agent from the TUI's agent tree. Tool identity now carries the intent, and the coordinator records it as a wait_kind that survives snapshot/restore: respond_to_user -> wait_kind="user", never auto-resumed (root or not) wait_for_agents -> wait_kind="agents", auto-resumed on a 300s timer recovery exhaust -> wait_kind="stalled" respond_to_user fuses the message and the yield into one call, so there is no way to answer and then forget to stop - the two-step that gpt-4o-mini skipped 2/2 in live testing. Plain text still renders as before. Auto-resume is also bounded now: an agent that re-parks after every timeout burned a model turn every 300s for the rest of the scan (and, since parked children notify their parent, spammed the parent's inbox on the same cycle). After _MAX_IDLE_AUTO_RESUMES it stays parked until a real message arrives. --- strix/agents/factory.py | 14 ++- strix/agents/prompts/system_prompt.jinja | 17 +-- strix/core/agents.py | 40 ++++++- strix/core/execution.py | 61 +++++++--- strix/interface/tui/renderers/__init__.py | 2 + .../tui/renderers/agents_graph_renderer.py | 4 +- .../tui/renderers/respond_renderer.py | 35 ++++++ .../tool-renderers/AgentCommsRenderer.tsx | 2 +- .../live/tool-renderers/RespondRenderer.tsx | 20 ++++ .../components/live/tool-renderers/index.ts | 9 +- .../{index-DzvI_0HX.js => index-CGvQq6oe.js} | 100 ++++++++-------- strix/interface/viewer/static/index.html | 2 +- strix/tools/agents_graph/tools.py | 47 ++++---- strix/tools/finish/tool.py | 4 +- strix/tools/respond/__init__.py | 6 + strix/tools/respond/tool.py | 110 ++++++++++++++++++ tests/test_agent_tool_registration.py | 28 ++++- tests/test_e2e_budget_lifecycle.py | 35 ++++-- tests/test_execution.py | 65 ++++++++++- tests/test_respond_to_user.py | 66 +++++++++++ 20 files changed, 539 insertions(+), 128 deletions(-) create mode 100644 strix/interface/tui/renderers/respond_renderer.py create mode 100644 strix/interface/viewer/frontend/src/components/live/tool-renderers/RespondRenderer.tsx rename strix/interface/viewer/static/assets/{index-DzvI_0HX.js => index-CGvQq6oe.js} (84%) create mode 100644 strix/tools/respond/__init__.py create mode 100644 strix/tools/respond/tool.py create mode 100644 tests/test_respond_to_user.py diff --git a/strix/agents/factory.py b/strix/agents/factory.py index 5d922b19..77e6c474 100644 --- a/strix/agents/factory.py +++ b/strix/agents/factory.py @@ -23,7 +23,7 @@ from strix.tools.agents_graph.tools import ( send_message_to_agent, stop_agent, view_agent_graph, - wait_for_message, + wait_for_agents, ) from strix.tools.finish.tool import finish_scan from strix.tools.load_skill.tool import load_skill @@ -49,6 +49,7 @@ from strix.tools.reporting.tool import ( get_report, list_reports, ) +from strix.tools.respond.tool import respond_to_user from strix.tools.thinking.tool import think from strix.tools.todo.tools import ( create_todo, @@ -345,6 +346,10 @@ def _make_shell_configurator(*, chat_completions: bool) -> Any: return configure +# Tools that hand control away by parking the agent rather than ending the scan. +_PARKING_TOOLS: frozenset[str] = frozenset({"respond_to_user", "wait_for_agents"}) + + def _lifecycle_tool_completed(tool_name: str, output: Any) -> bool: if tool_name == "agent_finish": completion_key = "agent_completed" @@ -363,7 +368,7 @@ def _lifecycle_tool_completed(tool_name: str, output: Any) -> bool: def _wait_tool_parked(tool_name: str, output: Any) -> bool: - if tool_name != "wait_for_message" or not isinstance(output, str): + if tool_name not in _PARKING_TOOLS or not isinstance(output, str): return False try: parsed = json.loads(output) @@ -425,7 +430,7 @@ _BASE_TOOLS: tuple[Tool, ...] = ( scope_rules, view_agent_graph, send_message_to_agent, - wait_for_message, + wait_for_agents, create_agent, stop_agent, ) @@ -509,6 +514,9 @@ def build_strix_agent( ) agent_tools = [*_EXTRA_TOOLS, *(extra_tools or [])] + if interactive: + # Yielding to the user is only meaningful when one is attached. + agent_tools.append(respond_to_user) if is_root: tools: list[Tool] = [*_BASE_TOOLS, *agent_tools, finish_scan] else: diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja index bb5924fb..44faecab 100644 --- a/strix/agents/prompts/system_prompt.jinja +++ b/strix/agents/prompts/system_prompt.jinja @@ -33,22 +33,23 @@ INTER-AGENT MESSAGES: INTERACTIVE BEHAVIOR: - You are in an interactive conversation with a user. - HOW EXECUTION ENDS: your turn ends ONLY when you make an explicit lifecycle tool call. Plain text NEVER ends your turn and NEVER hands control to the user — text is shown to the user, and then execution continues. - - To hand control back to the user (you answered them, or you need their input before continuing), call the wait_for_message tool. This is the ONLY sanctioned way to yield to the user; it parks you until the user's next message arrives. + - To answer the user and hand control back, call respond_to_user. It delivers your message AND parks you for their reply in one call, so there is no way to answer and then forget to stop. This is the ONLY way to yield to the user. + - To wait on another AGENT (a child's report, a peer's reply), call wait_for_agents. That is not a way to reach the user. - To end the whole engagement, call the lifecycle tool: finish_scan (root) or agent_finish (subagent). - A turn that ends with plain text and no tool call does NOT stop you: the system nudges you to continue and will re-run you. Do not rely on going silent to pause — it will not pause you. -- Answering a user question: put your answer in text, then call wait_for_message in the SAME turn (the text is delivered to the user and you park for their reply). Do not answer and then fall silent — that just triggers a continuation nudge. -- You may include brief explanatory text before a tool call. +- Answering a user question: put the answer in respond_to_user's message. Do not write the answer as plain text and then fall silent — that does not reach a stopping point, it just triggers a continuation nudge. +- You may include brief explanatory text before a tool call, and you can narrate while you work — plain text is shown to the user as you go. Narrating is free; respond_to_user is specifically the act of WAITING for the user, so do not call it just to give a status update. - Respond naturally when the user asks questions or gives instructions. -- While actively working on a task, every turn should carry exactly one tool call — use think to plan, the appropriate tool to act, and wait_for_message only when you genuinely need the user. -- Never loop through think or other tools just to prepare, polish, confirm, or announce an answer. Once you know the answer, say it (then wait_for_message). +- While actively working on a task, every turn should carry exactly one tool call — use think to plan, the appropriate tool to act, and respond_to_user only when you genuinely need the user. +- Never loop through think or other tools just to prepare, polish, confirm, or announce an answer. Once you know the answer, send it with respond_to_user. {% else %} AUTONOMOUS BEHAVIOR: - Work autonomously by default - You should NOT ask for user input or confirmation - you should always proceed with your task autonomously. - Minimize user messaging: avoid redundancy and repetition; consolidate updates into a single concise message -- NEVER send an empty or blank message. If you have no content to output or need to wait (for user input, subagent results, or any other reason), you MUST call the wait_for_message tool (or another appropriate tool) instead of emitting an empty response. -- If there is nothing to execute and no user query to answer any more: do NOT send filler/repetitive text — either call wait_for_message or finish your work (subagents: agent_finish; root: finish_scan) -- While the agent loop is running, almost every output MUST be a tool call. Do NOT send plain text messages; act via tools. If idle, use wait_for_message; when done, use agent_finish (subagents) or finish_scan (root) +- NEVER send an empty or blank message. If you have no content to output or need to wait for subagent results, you MUST call the wait_for_agents tool (or another appropriate tool) instead of emitting an empty response. +- There is no user attached to this run, so there is nobody to ask and nothing to yield to. If there is nothing left to execute: do NOT send filler/repetitive text — either call wait_for_agents (only if you are genuinely expecting another agent to message you) or finish your work (subagents: agent_finish; root: finish_scan) +- While the agent loop is running, almost every output MUST be a tool call. Do NOT send plain text messages; act via tools. If waiting on another agent, use wait_for_agents; when done, use agent_finish (subagents) or finish_scan (root) - A text-only turn — even one — IMMEDIATELY ends the scan/run with no report written. The lifecycle tools (``finish_scan`` for root, ``agent_finish`` for subagents) are the ONLY valid way to terminate. If you find yourself wanting to say "Done!" or "Scan complete" without a tool call, call the lifecycle tool instead — the report and termination signal both flow through it. {% endif %} diff --git a/strix/core/agents.py b/strix/core/agents.py index d03636e1..e4b26989 100644 --- a/strix/core/agents.py +++ b/strix/core/agents.py @@ -24,6 +24,11 @@ logger = logging.getLogger(__name__) Status = Literal["running", "waiting", "completed", "stopped", "crashed", "failed", "budget_paused"] +# Why an agent parked. The user can message any agent, so this - not the agent's +# position in the tree - decides whether waiting is bounded: only an agent waiting +# on other agents is re-checked on a timer. +WaitKind = Literal["user", "agents", "stalled"] + @dataclass(slots=True) class AgentRuntime: @@ -47,6 +52,8 @@ class AgentCoordinator: self.pending_counts: dict[str, int] = {} self.errors: dict[str, str] = {} self.recovery_counts: dict[str, int] = {} + self.idle_resume_counts: dict[str, int] = {} + self.wait_kinds: dict[str, WaitKind] = {} self.runtimes: dict[str, AgentRuntime] = {} self._lock = asyncio.Lock() self._snapshot_path: Path | None = None @@ -182,12 +189,21 @@ class AgentCoordinator: if agent_id in self.statuses: self.statuses[agent_id] = "running" self.errors.pop(agent_id, None) + self.wait_kinds.pop(agent_id, None) self.runtimes.setdefault(agent_id, AgentRuntime()).user_wake_required = False await self._maybe_snapshot() - async def park_waiting(self, agent_id: str) -> None: + async def park_waiting(self, agent_id: str, *, wait_kind: WaitKind) -> None: + """Park an agent, recording what it is waiting on so the driver can time it.""" + async with self._lock: + if agent_id in self.statuses: + self.wait_kinds[agent_id] = wait_kind await self.set_status(agent_id, "waiting") + async def wait_kind_of(self, agent_id: str) -> WaitKind | None: + async with self._lock: + return self.wait_kinds.get(agent_id) + async def record_recovery(self, agent_id: str) -> int: """Count a turn that ended without a lifecycle tool call; return the new total. @@ -207,6 +223,24 @@ class AgentCoordinator: return await self._maybe_snapshot() + async def record_idle_resume(self, agent_id: str) -> int: + """Count an auto-resume that no message triggered; return the new total. + + An agent that parks again after every auto-resume would otherwise burn a + model turn per timeout for the rest of the scan. + """ + async with self._lock: + count = self.idle_resume_counts.get(agent_id, 0) + 1 + self.idle_resume_counts[agent_id] = count + await self._maybe_snapshot() + return count + + async def reset_idle_resumes(self, agent_id: str) -> None: + async with self._lock: + if self.idle_resume_counts.pop(agent_id, None) is None: + return + await self._maybe_snapshot() + async def set_status( self, agent_id: str, status: Status | str, *, error: str | None = None ) -> None: @@ -418,6 +452,8 @@ class AgentCoordinator: "metadata": {aid: dict(md) for aid, md in self.metadata.items()}, "pending_counts": dict(self.pending_counts), "recovery_counts": dict(self.recovery_counts), + "idle_resume_counts": dict(self.idle_resume_counts), + "wait_kinds": dict(self.wait_kinds), "mailboxes": { aid: [dict(m) for m in runtime.mailbox] for aid, runtime in self.runtimes.items() @@ -438,6 +474,8 @@ class AgentCoordinator: self.pending_counts = dict(snap.get("pending_counts", {})) self.errors = dict(snap.get("errors", {})) self.recovery_counts = dict(snap.get("recovery_counts", {})) + self.idle_resume_counts = dict(snap.get("idle_resume_counts", {})) + self.wait_kinds = dict(snap.get("wait_kinds", {})) mailboxes = snap.get("mailboxes", {}) if isinstance(mailboxes, dict): for aid, msgs in mailboxes.items(): diff --git a/strix/core/execution.py b/strix/core/execution.py index 5b27a4cc..9822ec82 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -227,7 +227,7 @@ async def run_agent_loop( return result while True: - timeout = await _plain_waiting_timeout(coordinator, agent_id, context) + timeout = await _plain_waiting_timeout(coordinator, agent_id) try: woke = await coordinator.wait_for_message(agent_id, timeout=timeout) except asyncio.CancelledError: @@ -245,7 +245,19 @@ async def run_agent_loop( # Real input is real progress, so the nudge budget starts over. A bare # auto-resume is not: it must not hand a wedged agent a fresh budget. await coordinator.reset_recovery(agent_id) + await coordinator.reset_idle_resumes(agent_id) else: + idle_resumes = await coordinator.record_idle_resume(agent_id) + if idle_resumes >= _MAX_IDLE_AUTO_RESUMES: + logger.warning( + "agent %s auto-resumed %d times without hearing from anyone; " + "leaving it parked until a real message arrives", + agent_id, + idle_resumes, + ) + await coordinator.park_waiting(agent_id, wait_kind="stalled") + await _notify_parent_on_stall(coordinator, agent_id) + continue logger.info("agent %s reached its waiting timeout; auto-resuming", agent_id) await coordinator.send( agent_id, @@ -438,10 +450,10 @@ async def _run_until_lifecycle( ) -> RunResultBase | None: """Drive an agent until an explicit lifecycle tool settles its status. - A turn that ends without ``finish_scan``, ``agent_finish``, or - ``wait_for_message`` leaves the agent ``running``: plain text never - terminates a run and never yields to the user. Such a turn is nudged back - into a tool call, bounded by a recovery limit. + A turn that ends without ``finish_scan``, ``agent_finish``, + ``respond_to_user``, or ``wait_for_agents`` leaves the agent ``running``: + plain text never terminates a run and never yields to the user. Such a turn + is nudged back into a tool call, bounded by a recovery limit. """ result: RunResultBase | None = None input_data: Any = initial_input @@ -521,8 +533,8 @@ async def _exhausted_recovery( ) -> RunResultBase | None: """Settle an agent that never recovered into a tool call. - Interactive runs park instead of dying: a human is present, so the scan - stays resumable by sending another message. Autonomous runs have nobody to + Interactive runs park instead of dying: a human is attached and can message + any agent, so the scan stays resumable. Autonomous runs have nobody to resume them, so they fail loudly. """ if not interactive: @@ -536,7 +548,7 @@ async def _exhausted_recovery( "agent %s exhausted tool-call recovery attempts; parking until a message arrives", agent_id, ) - await coordinator.set_status(agent_id, "waiting") + await coordinator.park_waiting(agent_id, wait_kind="stalled") # A parked child owes its parent a completion report it can no longer send. The # parent is an agent, not a watching human, so nothing else tells it to stop # waiting and it burns its full timeout on a message that is never coming. @@ -546,23 +558,35 @@ async def _exhausted_recovery( _WAITING_AUTO_RESUME_TIMEOUT_S = 300.0 +# An agent that parks again after every auto-resume makes no progress, so stop +# spending a model turn per timeout and leave it parked for a real message. +_MAX_IDLE_AUTO_RESUMES = 3 + async def _plain_waiting_timeout( coordinator: AgentCoordinator, agent_id: str, - context: dict[str, Any], ) -> float | None: - """Auto-resume timeout for a plainly-waiting subagent; None waits forever.""" - if context.get("parent_id") is None: - return None + """Auto-resume timeout for a parked agent; None waits until a message arrives. + + Driven by what the agent is waiting on, not by where it sits in the graph: + the user can message any agent, so an agent awaiting a human parks + indefinitely whether or not it is the root. Only an agent awaiting other + agents is re-checked on a timer, and only until it has spent its idle + budget re-parking without hearing anything. + """ async with coordinator._lock: status = coordinator.statuses.get(agent_id) has_error = agent_id in coordinator.errors runtime = coordinator.runtimes.get(agent_id) gated = runtime.user_wake_required if runtime is not None else False - if status == "waiting" and not has_error and not gated: - return _WAITING_AUTO_RESUME_TIMEOUT_S - return None + wait_kind = coordinator.wait_kinds.get(agent_id) + idle_resumes = coordinator.idle_resume_counts.get(agent_id, 0) + if status != "waiting" or has_error or gated: + return None + if wait_kind != "agents" or idle_resumes >= _MAX_IDLE_AUTO_RESUMES: + return None + return _WAITING_AUTO_RESUME_TIMEOUT_S async def _run_cycle_parked( @@ -801,8 +825,9 @@ async def _append_tool_required_message( "Your previous message ended a turn without a tool call. Plain text never ends " "execution and never hands control to the user: it is shown to the user, and the " "run continues. Continue immediately and call exactly one tool. " - "If you have finished responding and want the user's next message, call " - "wait_for_message. " + "If you have something to tell the user and nothing to do until they reply, " + "call respond_to_user. " + "If you are blocked waiting for another agent, call wait_for_agents. " f"If the whole engagement is complete, call {finish_tool}. " "Otherwise use the appropriate execution or planning tool. " f"This is recovery attempt {attempt}/{limit}." @@ -813,7 +838,7 @@ async def _append_tool_required_message( "call. That is invalid in non-interactive mode; plain text final answers are " "ignored. Continue immediately and call exactly one tool. " f"If your work is complete, call {finish_tool}. " - "If you are blocked waiting for another agent, call wait_for_message. " + "If you are blocked waiting for another agent, call wait_for_agents. " "Otherwise use the appropriate execution or planning tool. " f"This is recovery attempt {attempt}/{limit}." ) diff --git a/strix/interface/tui/renderers/__init__.py b/strix/interface/tui/renderers/__init__.py index 75f60d7b..1535b6f4 100644 --- a/strix/interface/tui/renderers/__init__.py +++ b/strix/interface/tui/renderers/__init__.py @@ -6,6 +6,7 @@ from . import ( notes_renderer, proxy_renderer, reporting_renderer, + respond_renderer, shell_renderer, thinking_renderer, todo_renderer, @@ -23,6 +24,7 @@ __all__ = [ "proxy_renderer", "render_tool_widget", "reporting_renderer", + "respond_renderer", "shell_renderer", "thinking_renderer", "todo_renderer", diff --git a/strix/interface/tui/renderers/agents_graph_renderer.py b/strix/interface/tui/renderers/agents_graph_renderer.py index 92ad1d41..de359dc8 100644 --- a/strix/interface/tui/renderers/agents_graph_renderer.py +++ b/strix/interface/tui/renderers/agents_graph_renderer.py @@ -117,8 +117,8 @@ class AgentFinishRenderer(BaseToolRenderer): @register_tool_renderer -class WaitForMessageRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "wait_for_message" +class WaitForAgentsRenderer(BaseToolRenderer): + tool_name: ClassVar[str] = "wait_for_agents" css_classes: ClassVar[list[str]] = ["tool-call", "agents-graph-tool"] @classmethod diff --git a/strix/interface/tui/renderers/respond_renderer.py b/strix/interface/tui/renderers/respond_renderer.py new file mode 100644 index 00000000..ed80c08d --- /dev/null +++ b/strix/interface/tui/renderers/respond_renderer.py @@ -0,0 +1,35 @@ +from typing import Any, ClassVar + +from rich.text import Text +from textual.widgets import Static + +from .agent_message_renderer import AgentMessageRenderer +from .base_renderer import BaseToolRenderer +from .registry import register_tool_renderer + + +@register_tool_renderer +class RespondToUserRenderer(BaseToolRenderer): + """Render a reply as the agent's own prose, not as a tool call. + + ``respond_to_user`` carries the message the user is meant to read, so it + gets the same markdown treatment as a plain assistant turn. + """ + + tool_name: ClassVar[str] = "respond_to_user" + css_classes: ClassVar[list[str]] = ["tool-call", "respond-tool"] + + @classmethod + def render(cls, tool_data: dict[str, Any]) -> Static: + args = tool_data.get("args", {}) + message = args.get("message", "") + + text = Text() + if message: + text.append_text(AgentMessageRenderer.render_simple(message)) + text.append("\n\n") + text.append("○ ", style="#6b7280") + text.append("waiting for your reply", style="dim") + + css_classes = cls.get_css_classes(tool_data.get("status", "unknown")) + return Static(text, classes=css_classes) diff --git a/strix/interface/viewer/frontend/src/components/live/tool-renderers/AgentCommsRenderer.tsx b/strix/interface/viewer/frontend/src/components/live/tool-renderers/AgentCommsRenderer.tsx index 49d8bfcc..5bbbeeec 100644 --- a/strix/interface/viewer/frontend/src/components/live/tool-renderers/AgentCommsRenderer.tsx +++ b/strix/interface/viewer/frontend/src/components/live/tool-renderers/AgentCommsRenderer.tsx @@ -54,7 +54,7 @@ export default function AgentCommsRenderer({ toolName, args }: ToolRendererProps ); } - if (toolName === "wait_for_message") { + if (toolName === "wait_for_agents") { const reason = (args.reason as string) ?? ""; return (
diff --git a/strix/interface/viewer/frontend/src/components/live/tool-renderers/RespondRenderer.tsx b/strix/interface/viewer/frontend/src/components/live/tool-renderers/RespondRenderer.tsx new file mode 100644 index 00000000..e963a35e --- /dev/null +++ b/strix/interface/viewer/frontend/src/components/live/tool-renderers/RespondRenderer.tsx @@ -0,0 +1,20 @@ +"use client"; + +import type { ToolRendererProps } from "@/types/events"; +import Markdown from "./Markdown"; + +/** + * `respond_to_user` carries the message the user is meant to read, so it renders + * as the agent's own prose rather than as a tool call. + */ +export default function RespondRenderer({ args }: ToolRendererProps) { + const message = (args.message as string) ?? ""; + if (!message) return null; + + return ( +
+ +
waiting for your reply
+
+ ); +} diff --git a/strix/interface/viewer/frontend/src/components/live/tool-renderers/index.ts b/strix/interface/viewer/frontend/src/components/live/tool-renderers/index.ts index 545c8853..67f275fc 100644 --- a/strix/interface/viewer/frontend/src/components/live/tool-renderers/index.ts +++ b/strix/interface/viewer/frontend/src/components/live/tool-renderers/index.ts @@ -24,6 +24,7 @@ import NotesRenderer from "./NotesRenderer"; import TodoRenderer from "./TodoRenderer"; import FallbackRenderer from "./FallbackRenderer"; import LoadSkillRenderer from "./LoadSkillRenderer"; +import RespondRenderer from "./RespondRenderer"; /** * Tool-renderer mapping — data-driven, keyed by the engine's tool *family*. @@ -104,10 +105,10 @@ const CATEGORY_TOOLS: Record = { proxy: ["list_requests", "view_request", "repeat_request", "list_sitemap", "view_sitemap_entry", "scope_rules", "send_request"], reporting: ["create_vulnerability_report", "list_reports", "get_report"], thinking: ["think"], - agents: ["create_agent", "agent_finish", "send_message_to_agent", "wait_for_message", "view_agent_graph", "stop_agent"], + agents: ["create_agent", "agent_finish", "send_message_to_agent", "wait_for_agents", "view_agent_graph", "stop_agent"], search: ["web_search"], // scan_start_info / subagent_start_info are strix-app synthetic events; finish_scan is the engine's - lifecycle: ["scan_start_info", "subagent_start_info", "finish_scan"], + lifecycle: ["scan_start_info", "subagent_start_info", "finish_scan", "respond_to_user"], notes: ["create_note", "delete_note", "update_note", "list_notes", "get_note"], skills: ["load_skill"], todos: ["create_todo", "list_todos", "update_todo", "mark_todo_done", "mark_todo_pending", "delete_todo"], @@ -127,6 +128,7 @@ const TOOL_CATEGORY: Record = Object.fromEntries( */ const RENDERER_OVERRIDES: Partial>> = { finish_scan: FinishRenderer, + respond_to_user: RespondRenderer, apply_patch: ApplyPatchRenderer, view_image: ViewImageRenderer, list_reports: ReportListRenderer, @@ -140,7 +142,8 @@ const RENDERER_OVERRIDES: Partial> = { agent_finish: { icon: Flag, color: "text-cyan-400" }, send_message_to_agent: { icon: MessageCircle, color: "text-cyan-400" }, - wait_for_message: { icon: MessageCircle, color: "text-cyan-400" }, + wait_for_agents: { icon: MessageCircle, color: "text-cyan-400" }, + respond_to_user: { icon: MessageCircle, color: "text-emerald-400" }, view_agent_graph: { icon: Eye, color: "text-cyan-400" }, stop_agent: { icon: Ban, color: "text-red-400" }, scan_start_info: { icon: Crosshair, color: "text-emerald-400" }, diff --git a/strix/interface/viewer/static/assets/index-DzvI_0HX.js b/strix/interface/viewer/static/assets/index-CGvQq6oe.js similarity index 84% rename from strix/interface/viewer/static/assets/index-DzvI_0HX.js rename to strix/interface/viewer/static/assets/index-CGvQq6oe.js index aa014bd5..a6e52674 100644 --- a/strix/interface/viewer/static/assets/index-DzvI_0HX.js +++ b/strix/interface/viewer/static/assets/index-CGvQq6oe.js @@ -6,7 +6,7 @@ * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. - */var G0;function yk(){if(G0)return Yl;G0=1;var e=Symbol.for("react.transitional.element"),t=Symbol.for("react.fragment");function r(a,s,o){var c=null;if(o!==void 0&&(c=""+o),s.key!==void 0&&(c=""+s.key),"key"in s){o={};for(var d in s)d!=="key"&&(o[d]=s[d])}else o=s;return s=o.ref,{$$typeof:e,type:a,key:c,ref:s!==void 0?s:null,props:o}}return Yl.Fragment=t,Yl.jsx=r,Yl.jsxs=r,Yl}var V0;function vk(){return V0||(V0=1,oh.exports=yk()),oh.exports}var g=vk(),ch={exports:{}},Ve={};/** + */var V0;function yk(){if(V0)return Yl;V0=1;var e=Symbol.for("react.transitional.element"),t=Symbol.for("react.fragment");function r(a,s,o){var c=null;if(o!==void 0&&(c=""+o),s.key!==void 0&&(c=""+s.key),"key"in s){o={};for(var d in s)d!=="key"&&(o[d]=s[d])}else o=s;return s=o.ref,{$$typeof:e,type:a,key:c,ref:s!==void 0?s:null,props:o}}return Yl.Fragment=t,Yl.jsx=r,Yl.jsxs=r,Yl}var Y0;function vk(){return Y0||(Y0=1,oh.exports=yk()),oh.exports}var g=vk(),ch={exports:{}},Ve={};/** * @license React * react.production.js * @@ -14,7 +14,7 @@ * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. - */var Y0;function _k(){if(Y0)return Ve;Y0=1;var e=Symbol.for("react.transitional.element"),t=Symbol.for("react.portal"),r=Symbol.for("react.fragment"),a=Symbol.for("react.strict_mode"),s=Symbol.for("react.profiler"),o=Symbol.for("react.consumer"),c=Symbol.for("react.context"),d=Symbol.for("react.forward_ref"),h=Symbol.for("react.suspense"),f=Symbol.for("react.memo"),m=Symbol.for("react.lazy"),p=Symbol.for("react.activity"),y=Symbol.iterator;function x(D){return D===null||typeof D!="object"?null:(D=y&&D[y]||D["@@iterator"],typeof D=="function"?D:null)}var _={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},N=Object.assign,S={};function w(D,Y,L){this.props=D,this.context=Y,this.refs=S,this.updater=L||_}w.prototype.isReactComponent={},w.prototype.setState=function(D,Y){if(typeof D!="object"&&typeof D!="function"&&D!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,D,Y,"setState")},w.prototype.forceUpdate=function(D){this.updater.enqueueForceUpdate(this,D,"forceUpdate")};function k(){}k.prototype=w.prototype;function E(D,Y,L){this.props=D,this.context=Y,this.refs=S,this.updater=L||_}var M=E.prototype=new k;M.constructor=E,N(M,w.prototype),M.isPureReactComponent=!0;var I=Array.isArray;function R(){}var U={H:null,A:null,T:null,S:null},B=Object.prototype.hasOwnProperty;function Z(D,Y,L){var G=L.ref;return{$$typeof:e,type:D,key:Y,ref:G!==void 0?G:null,props:L}}function j(D,Y){return Z(D.type,Y,D.props)}function z(D){return typeof D=="object"&&D!==null&&D.$$typeof===e}function V(D){var Y={"=":"=0",":":"=2"};return"$"+D.replace(/[=:]/g,function(L){return Y[L]})}var P=/\/+/g;function T(D,Y){return typeof D=="object"&&D!==null&&D.key!=null?V(""+D.key):Y.toString(36)}function $(D){switch(D.status){case"fulfilled":return D.value;case"rejected":throw D.reason;default:switch(typeof D.status=="string"?D.then(R,R):(D.status="pending",D.then(function(Y){D.status==="pending"&&(D.status="fulfilled",D.value=Y)},function(Y){D.status==="pending"&&(D.status="rejected",D.reason=Y)})),D.status){case"fulfilled":return D.value;case"rejected":throw D.reason}}throw D}function O(D,Y,L,G,q){var Q=typeof D;(Q==="undefined"||Q==="boolean")&&(D=null);var J=!1;if(D===null)J=!0;else switch(Q){case"bigint":case"string":case"number":J=!0;break;case"object":switch(D.$$typeof){case e:case t:J=!0;break;case m:return J=D._init,O(J(D._payload),Y,L,G,q)}}if(J)return q=q(D),J=G===""?"."+T(D,0):G,I(q)?(L="",J!=null&&(L=J.replace(P,"$&/")+"/"),O(q,Y,L,"",function(ce){return ce})):q!=null&&(z(q)&&(q=j(q,L+(q.key==null||D&&D.key===q.key?"":(""+q.key).replace(P,"$&/")+"/")+J)),Y.push(q)),1;J=0;var W=G===""?".":G+":";if(I(D))for(var te=0;te>>1,C=O[K];if(0>>1;Ks(L,X))Gs(q,L)?(O[K]=q,O[G]=X,K=G):(O[K]=L,O[Y]=X,K=Y);else if(Gs(q,X))O[K]=q,O[G]=X,K=G;else break e}}return H}function s(O,H){var X=O.sortIndex-H.sortIndex;return X!==0?X:O.id-H.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var o=performance;e.unstable_now=function(){return o.now()}}else{var c=Date,d=c.now();e.unstable_now=function(){return c.now()-d}}var h=[],f=[],m=1,p=null,y=3,x=!1,_=!1,N=!1,S=!1,w=typeof setTimeout=="function"?setTimeout:null,k=typeof clearTimeout=="function"?clearTimeout:null,E=typeof setImmediate<"u"?setImmediate:null;function M(O){for(var H=r(f);H!==null;){if(H.callback===null)a(f);else if(H.startTime<=O)a(f),H.sortIndex=H.expirationTime,t(h,H);else break;H=r(f)}}function I(O){if(N=!1,M(O),!_)if(r(h)!==null)_=!0,R||(R=!0,V());else{var H=r(f);H!==null&&$(I,H.startTime-O)}}var R=!1,U=-1,B=5,Z=-1;function j(){return S?!0:!(e.unstable_now()-ZO&&j());){var K=p.callback;if(typeof K=="function"){p.callback=null,y=p.priorityLevel;var C=K(p.expirationTime<=O);if(O=e.unstable_now(),typeof C=="function"){p.callback=C,M(O),H=!0;break t}p===r(h)&&a(h),M(O)}else a(h);p=r(h)}if(p!==null)H=!0;else{var D=r(f);D!==null&&$(I,D.startTime-O),H=!1}}break e}finally{p=null,y=X,x=!1}H=void 0}}finally{H?V():R=!1}}}var V;if(typeof E=="function")V=function(){E(z)};else if(typeof MessageChannel<"u"){var P=new MessageChannel,T=P.port2;P.port1.onmessage=z,V=function(){T.postMessage(null)}}else V=function(){w(z,0)};function $(O,H){U=w(function(){O(e.unstable_now())},H)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(O){O.callback=null},e.unstable_forceFrameRate=function(O){0>O||125K?(O.sortIndex=X,t(f,O),r(h)===null&&O===r(f)&&(N?(k(U),U=-1):N=!0,$(I,X-K))):(O.sortIndex=C,t(h,O),_||x||(_=!0,R||(R=!0,V()))),O},e.unstable_shouldYield=j,e.unstable_wrapCallback=function(O){var H=y;return function(){var X=y;y=H;try{return O.apply(this,arguments)}finally{y=X}}}})(fh)),fh}var Z0;function Ek(){return Z0||(Z0=1,dh.exports=wk()),dh.exports}var hh={exports:{}},Cn={};/** + */var Z0;function wk(){return Z0||(Z0=1,(function(e){function t(O,H){var X=O.length;O.push(H);e:for(;0>>1,C=O[K];if(0>>1;Ks(L,X))Gs(q,L)?(O[K]=q,O[G]=X,K=G):(O[K]=L,O[Y]=X,K=Y);else if(Gs(q,X))O[K]=q,O[G]=X,K=G;else break e}}return H}function s(O,H){var X=O.sortIndex-H.sortIndex;return X!==0?X:O.id-H.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var o=performance;e.unstable_now=function(){return o.now()}}else{var c=Date,d=c.now();e.unstable_now=function(){return c.now()-d}}var h=[],f=[],m=1,p=null,y=3,x=!1,_=!1,N=!1,S=!1,w=typeof setTimeout=="function"?setTimeout:null,k=typeof clearTimeout=="function"?clearTimeout:null,E=typeof setImmediate<"u"?setImmediate:null;function M(O){for(var H=r(f);H!==null;){if(H.callback===null)a(f);else if(H.startTime<=O)a(f),H.sortIndex=H.expirationTime,t(h,H);else break;H=r(f)}}function I(O){if(N=!1,M(O),!_)if(r(h)!==null)_=!0,R||(R=!0,V());else{var H=r(f);H!==null&&$(I,H.startTime-O)}}var R=!1,U=-1,B=5,Z=-1;function j(){return S?!0:!(e.unstable_now()-ZO&&j());){var K=p.callback;if(typeof K=="function"){p.callback=null,y=p.priorityLevel;var C=K(p.expirationTime<=O);if(O=e.unstable_now(),typeof C=="function"){p.callback=C,M(O),H=!0;break t}p===r(h)&&a(h),M(O)}else a(h);p=r(h)}if(p!==null)H=!0;else{var D=r(f);D!==null&&$(I,D.startTime-O),H=!1}}break e}finally{p=null,y=X,x=!1}H=void 0}}finally{H?V():R=!1}}}var V;if(typeof E=="function")V=function(){E(z)};else if(typeof MessageChannel<"u"){var P=new MessageChannel,T=P.port2;P.port1.onmessage=z,V=function(){T.postMessage(null)}}else V=function(){w(z,0)};function $(O,H){U=w(function(){O(e.unstable_now())},H)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(O){O.callback=null},e.unstable_forceFrameRate=function(O){0>O||125K?(O.sortIndex=X,t(f,O),r(h)===null&&O===r(f)&&(N?(k(U),U=-1):N=!0,$(I,X-K))):(O.sortIndex=C,t(h,O),_||x||(_=!0,R||(R=!0,V()))),O},e.unstable_shouldYield=j,e.unstable_wrapCallback=function(O){var H=y;return function(){var X=y;y=H;try{return O.apply(this,arguments)}finally{y=X}}}})(fh)),fh}var Q0;function Ek(){return Q0||(Q0=1,dh.exports=wk()),dh.exports}var hh={exports:{}},Cn={};/** * @license React * react-dom.production.js * @@ -30,7 +30,7 @@ * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. - */var Q0;function Nk(){if(Q0)return Cn;Q0=1;var e=To();function t(h){var f="https://react.dev/errors/"+h;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(t){console.error(t)}}return e(),hh.exports=Nk(),hh.exports}/** + */var W0;function Nk(){if(W0)return Cn;W0=1;var e=To();function t(h){var f="https://react.dev/errors/"+h;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(t){console.error(t)}}return e(),hh.exports=Nk(),hh.exports}/** * @license React * react-dom-client.production.js * @@ -38,15 +38,15 @@ * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. - */var J0;function Sk(){if(J0)return Xl;J0=1;var e=Ek(),t=To(),r=k_();function a(n){var i="https://react.dev/errors/"+n;if(1C||(n.current=K[C],K[C]=null,C--)}function L(n,i){C++,K[C]=n.current,n.current=i}var G=D(null),q=D(null),Q=D(null),J=D(null);function W(n,i){switch(L(Q,i),L(q,n),L(G,null),i.nodeType){case 9:case 11:n=(n=i.documentElement)&&(n=n.namespaceURI)?m0(n):0;break;default:if(n=i.tagName,i=i.namespaceURI)i=m0(i),n=p0(i,n);else switch(n){case"svg":n=1;break;case"math":n=2;break;default:n=0}}Y(G),L(G,n)}function te(){Y(G),Y(q),Y(Q)}function ce(n){n.memoizedState!==null&&L(J,n);var i=G.current,l=p0(i,n.type);i!==l&&(L(q,n),L(G,l))}function fe(n){q.current===n&&(Y(G),Y(q)),J.current===n&&(Y(J),Pl._currentValue=X)}var be,we;function Ne(n){if(be===void 0)try{throw Error()}catch(l){var i=l.stack.trim().match(/\n( *(at )?)/);be=i&&i[1]||"",we=-1C||(n.current=K[C],K[C]=null,C--)}function L(n,i){C++,K[C]=n.current,n.current=i}var G=D(null),q=D(null),Q=D(null),J=D(null);function W(n,i){switch(L(Q,i),L(q,n),L(G,null),i.nodeType){case 9:case 11:n=(n=i.documentElement)&&(n=n.namespaceURI)?p0(n):0;break;default:if(n=i.tagName,i=i.namespaceURI)i=p0(i),n=g0(i,n);else switch(n){case"svg":n=1;break;case"math":n=2;break;default:n=0}}Y(G),L(G,n)}function te(){Y(G),Y(q),Y(Q)}function ce(n){n.memoizedState!==null&&L(J,n);var i=G.current,l=g0(i,n.type);i!==l&&(L(q,n),L(G,l))}function fe(n){q.current===n&&(Y(G),Y(q)),J.current===n&&(Y(J),Pl._currentValue=X)}var be,we;function Ne(n){if(be===void 0)try{throw Error()}catch(l){var i=l.stack.trim().match(/\n( *(at )?)/);be=i&&i[1]||"",we=-1)":-1b||ne[u]!==le[b]){var he=` `+ne[u].replace(" at new "," at ");return n.displayName&&he.includes("")&&(he=he.replace("",n.displayName)),he}while(1<=u&&0<=b);break}}}finally{De=!1,Error.prepareStackTrace=l}return(l=n?n.displayName||n.name:"")?Ne(l):""}function st(n,i){switch(n.tag){case 26:case 27:case 5:return Ne(n.type);case 16:return Ne("Lazy");case 13:return n.child!==i&&i!==null?Ne("Suspense Fallback"):Ne("Suspense");case 19:return Ne("SuspenseList");case 0:case 15:return $e(n.type,!1);case 11:return $e(n.type.render,!1);case 1:return $e(n.type,!0);case 31:return Ne("Activity");default:return""}}function Rt(n){try{var i="",l=null;do i+=st(n,l),l=n,n=n.return;while(n);return i}catch(u){return` Error generating stack: `+u.message+` -`+u.stack}}var Yt=Object.prototype.hasOwnProperty,Pt=e.unstable_scheduleCallback,Xt=e.unstable_cancelCallback,Yn=e.unstable_shouldYield,En=e.unstable_requestPaint,ct=e.unstable_now,It=e.unstable_getCurrentPriorityLevel,ue=e.unstable_ImmediatePriority,xe=e.unstable_UserBlockingPriority,Oe=e.unstable_NormalPriority,Fe=e.unstable_LowPriority,Ze=e.unstable_IdlePriority,on=e.log,Nn=e.unstable_setDisableYieldValue,Kt=null,At=null;function Wt(n){if(typeof on=="function"&&Nn(n),At&&typeof At.setStrictMode=="function")try{At.setStrictMode(Kt,n)}catch{}}var ut=Math.clz32?Math.clz32:Ni,In=Math.log,cn=Math.LN2;function Ni(n){return n>>>=0,n===0?32:31-(In(n)/cn|0)|0}var nt=256,Xn=262144,On=4194304;function hn(n){var i=n&42;if(i!==0)return i;switch(n&-n){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return n&261888;case 262144:case 524288:case 1048576:case 2097152:return n&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return n&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return n}}function re(n,i,l){var u=n.pendingLanes;if(u===0)return 0;var b=0,v=n.suspendedLanes,A=n.pingedLanes;n=n.warmLanes;var F=u&134217727;return F!==0?(u=F&~v,u!==0?b=hn(u):(A&=F,A!==0?b=hn(A):l||(l=F&~n,l!==0&&(b=hn(l))))):(F=u&~v,F!==0?b=hn(F):A!==0?b=hn(A):l||(l=u&~n,l!==0&&(b=hn(l)))),b===0?0:i!==0&&i!==b&&(i&v)===0&&(v=b&-b,l=i&-i,v>=l||v===32&&(l&4194048)!==0)?i:b}function me(n,i){return(n.pendingLanes&~(n.suspendedLanes&~n.pingedLanes)&i)===0}function Ee(n,i){switch(n){case 1:case 2:case 4:case 8:case 64:return i+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return i+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Pe(){var n=On;return On<<=1,(On&62914560)===0&&(On=4194304),n}function St(n){for(var i=[],l=0;31>l;l++)i.push(n);return i}function gt(n,i){n.pendingLanes|=i,i!==268435456&&(n.suspendedLanes=0,n.pingedLanes=0,n.warmLanes=0)}function Ae(n,i,l,u,b,v){var A=n.pendingLanes;n.pendingLanes=l,n.suspendedLanes=0,n.pingedLanes=0,n.warmLanes=0,n.expiredLanes&=l,n.entangledLanes&=l,n.errorRecoveryDisabledLanes&=l,n.shellSuspendCounter=0;var F=n.entanglements,ne=n.expirationTimes,le=n.hiddenUpdates;for(l=A&~l;0"u")return null;try{return n.activeElement||n.body}catch{return n.body}}var rs=/[\n"\\]/g;function kn(n){return n.replace(rs,function(i){return"\\"+i.charCodeAt(0).toString(16)+" "})}function ba(n,i,l,u,b,v,A,F){n.name="",A!=null&&typeof A!="function"&&typeof A!="symbol"&&typeof A!="boolean"?n.type=A:n.removeAttribute("type"),i!=null?A==="number"?(i===0&&n.value===""||n.value!=i)&&(n.value=""+_t(i)):n.value!==""+_t(i)&&(n.value=""+_t(i)):A!=="submit"&&A!=="reset"||n.removeAttribute("value"),i!=null?Oi(n,A,_t(i)):l!=null?Oi(n,A,_t(l)):u!=null&&n.removeAttribute("value"),b==null&&v!=null&&(n.defaultChecked=!!v),b!=null&&(n.checked=b&&typeof b!="function"&&typeof b!="symbol"),F!=null&&typeof F!="function"&&typeof F!="symbol"&&typeof F!="boolean"?n.name=""+_t(F):n.removeAttribute("name")}function Er(n,i,l,u,b,v,A,F){if(v!=null&&typeof v!="function"&&typeof v!="symbol"&&typeof v!="boolean"&&(n.type=v),i!=null||l!=null){if(!(v!=="submit"&&v!=="reset"||i!=null)){Ai(n);return}l=l!=null?""+_t(l):"",i=i!=null?""+_t(i):l,F||i===n.value||(n.value=i),n.defaultValue=i}u=u??b,u=typeof u!="function"&&typeof u!="symbol"&&!!u,n.checked=F?n.checked:!!u,n.defaultChecked=!!u,A!=null&&typeof A!="function"&&typeof A!="symbol"&&typeof A!="boolean"&&(n.name=A),Ai(n)}function Oi(n,i,l){i==="number"&&Mi(n.ownerDocument)===n||n.defaultValue===""+l||(n.defaultValue=""+l)}function un(n,i,l,u){if(n=n.options,i){i={};for(var b=0;b"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),ld=!1;if(ti)try{var ll={};Object.defineProperty(ll,"passive",{get:function(){ld=!0}}),window.addEventListener("test",ll,ll),window.removeEventListener("test",ll,ll)}catch{ld=!1}var Di=null,od=null,qo=null;function pg(){if(qo)return qo;var n,i=od,l=i.length,u,b="value"in Di?Di.value:Di.textContent,v=b.length;for(n=0;n=ul),_g=" ",wg=!1;function Eg(n,i){switch(n){case"keyup":return $S.indexOf(i.keyCode)!==-1;case"keydown":return i.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Ng(n){return n=n.detail,typeof n=="object"&&"data"in n?n.data:null}var as=!1;function PS(n,i){switch(n){case"compositionend":return Ng(i);case"keypress":return i.which!==32?null:(wg=!0,_g);case"textInput":return n=i.data,n===_g&&wg?null:n;default:return null}}function FS(n,i){if(as)return n==="compositionend"||!hd&&Eg(n,i)?(n=pg(),qo=od=Di=null,as=!1,n):null;switch(n){case"paste":return null;case"keypress":if(!(i.ctrlKey||i.altKey||i.metaKey)||i.ctrlKey&&i.altKey){if(i.char&&1=i)return{node:l,offset:i-n};n=u}e:{for(;l;){if(l.nextSibling){l=l.nextSibling;break e}l=l.parentNode}l=void 0}l=Rg(l)}}function Dg(n,i){return n&&i?n===i?!0:n&&n.nodeType===3?!1:i&&i.nodeType===3?Dg(n,i.parentNode):"contains"in n?n.contains(i):n.compareDocumentPosition?!!(n.compareDocumentPosition(i)&16):!1:!1}function Lg(n){n=n!=null&&n.ownerDocument!=null&&n.ownerDocument.defaultView!=null?n.ownerDocument.defaultView:window;for(var i=Mi(n.document);i instanceof n.HTMLIFrameElement;){try{var l=typeof i.contentWindow.location.href=="string"}catch{l=!1}if(l)n=i.contentWindow;else break;i=Mi(n.document)}return i}function gd(n){var i=n&&n.nodeName&&n.nodeName.toLowerCase();return i&&(i==="input"&&(n.type==="text"||n.type==="search"||n.type==="tel"||n.type==="url"||n.type==="password")||i==="textarea"||n.contentEditable==="true")}var WS=ti&&"documentMode"in document&&11>=document.documentMode,ss=null,bd=null,ml=null,xd=!1;function zg(n,i,l){var u=l.window===l?l.document:l.nodeType===9?l:l.ownerDocument;xd||ss==null||ss!==Mi(u)||(u=ss,"selectionStart"in u&&gd(u)?u={start:u.selectionStart,end:u.selectionEnd}:(u=(u.ownerDocument&&u.ownerDocument.defaultView||window).getSelection(),u={anchorNode:u.anchorNode,anchorOffset:u.anchorOffset,focusNode:u.focusNode,focusOffset:u.focusOffset}),ml&&hl(ml,u)||(ml=u,u=Lc(bd,"onSelect"),0>=A,b-=A,Hr=1<<32-ut(i)+b|l<Ke?(at=je,je=null):at=je.sibling;var mt=oe(ae,je,se[Ke],pe);if(mt===null){je===null&&(je=at);break}n&&je&&mt.alternate===null&&i(ae,je),ie=v(mt,ie,Ke),ht===null?Ie=mt:ht.sibling=mt,ht=mt,je=at}if(Ke===se.length)return l(ae,je),lt&&ri(ae,Ke),Ie;if(je===null){for(;KeKe?(at=je,je=null):at=je.sibling;var na=oe(ae,je,mt.value,pe);if(na===null){je===null&&(je=at);break}n&&je&&na.alternate===null&&i(ae,je),ie=v(na,ie,Ke),ht===null?Ie=na:ht.sibling=na,ht=na,je=at}if(mt.done)return l(ae,je),lt&&ri(ae,Ke),Ie;if(je===null){for(;!mt.done;Ke++,mt=se.next())mt=ge(ae,mt.value,pe),mt!==null&&(ie=v(mt,ie,Ke),ht===null?Ie=mt:ht.sibling=mt,ht=mt);return lt&&ri(ae,Ke),Ie}for(je=u(je);!mt.done;Ke++,mt=se.next())mt=de(je,ae,Ke,mt.value,pe),mt!==null&&(n&&mt.alternate!==null&&je.delete(mt.key===null?Ke:mt.key),ie=v(mt,ie,Ke),ht===null?Ie=mt:ht.sibling=mt,ht=mt);return n&&je.forEach(function(xk){return i(ae,xk)}),lt&&ri(ae,Ke),Ie}function Nt(ae,ie,se,pe){if(typeof se=="object"&&se!==null&&se.type===N&&se.key===null&&(se=se.props.children),typeof se=="object"&&se!==null){switch(se.$$typeof){case x:e:{for(var Ie=se.key;ie!==null;){if(ie.key===Ie){if(Ie=se.type,Ie===N){if(ie.tag===7){l(ae,ie.sibling),pe=b(ie,se.props.children),pe.return=ae,ae=pe;break e}}else if(ie.elementType===Ie||typeof Ie=="object"&&Ie!==null&&Ie.$$typeof===B&&Ta(Ie)===ie.type){l(ae,ie.sibling),pe=b(ie,se.props),vl(pe,se),pe.return=ae,ae=pe;break e}l(ae,ie);break}else i(ae,ie);ie=ie.sibling}se.type===N?(pe=Ea(se.props.children,ae.mode,pe,se.key),pe.return=ae,ae=pe):(pe=Wo(se.type,se.key,se.props,null,ae.mode,pe),vl(pe,se),pe.return=ae,ae=pe)}return A(ae);case _:e:{for(Ie=se.key;ie!==null;){if(ie.key===Ie)if(ie.tag===4&&ie.stateNode.containerInfo===se.containerInfo&&ie.stateNode.implementation===se.implementation){l(ae,ie.sibling),pe=b(ie,se.children||[]),pe.return=ae,ae=pe;break e}else{l(ae,ie);break}else i(ae,ie);ie=ie.sibling}pe=Sd(se,ae.mode,pe),pe.return=ae,ae=pe}return A(ae);case B:return se=Ta(se),Nt(ae,ie,se,pe)}if($(se))return Te(ae,ie,se,pe);if(V(se)){if(Ie=V(se),typeof Ie!="function")throw Error(a(150));return se=Ie.call(se),He(ae,ie,se,pe)}if(typeof se.then=="function")return Nt(ae,ie,ac(se),pe);if(se.$$typeof===E)return Nt(ae,ie,tc(ae,se),pe);sc(ae,se)}return typeof se=="string"&&se!==""||typeof se=="number"||typeof se=="bigint"?(se=""+se,ie!==null&&ie.tag===6?(l(ae,ie.sibling),pe=b(ie,se),pe.return=ae,ae=pe):(l(ae,ie),pe=Nd(se,ae.mode,pe),pe.return=ae,ae=pe),A(ae)):l(ae,ie)}return function(ae,ie,se,pe){try{yl=0;var Ie=Nt(ae,ie,se,pe);return bs=null,Ie}catch(je){if(je===gs||je===rc)throw je;var ht=Zn(29,je,null,ae.mode);return ht.lanes=pe,ht.return=ae,ht}finally{}}}var Ma=ab(!0),sb=ab(!1),Ui=!1;function Id(n){n.updateQueue={baseState:n.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Bd(n,i){n=n.updateQueue,i.updateQueue===n&&(i.updateQueue={baseState:n.baseState,firstBaseUpdate:n.firstBaseUpdate,lastBaseUpdate:n.lastBaseUpdate,shared:n.shared,callbacks:null})}function Hi(n){return{lane:n,tag:0,payload:null,callback:null,next:null}}function $i(n,i,l){var u=n.updateQueue;if(u===null)return null;if(u=u.shared,(pt&2)!==0){var b=u.pending;return b===null?i.next=i:(i.next=b.next,b.next=i),u.pending=i,i=Qo(n),Pg(n,null,l),i}return Zo(n,u,i,l),Qo(n)}function _l(n,i,l){if(i=i.updateQueue,i!==null&&(i=i.shared,(l&4194048)!==0)){var u=i.lanes;u&=n.pendingLanes,l|=u,i.lanes=l,Ue(n,l)}}function Ud(n,i){var l=n.updateQueue,u=n.alternate;if(u!==null&&(u=u.updateQueue,l===u)){var b=null,v=null;if(l=l.firstBaseUpdate,l!==null){do{var A={lane:l.lane,tag:l.tag,payload:l.payload,callback:null,next:null};v===null?b=v=A:v=v.next=A,l=l.next}while(l!==null);v===null?b=v=i:v=v.next=i}else b=v=i;l={baseState:u.baseState,firstBaseUpdate:b,lastBaseUpdate:v,shared:u.shared,callbacks:u.callbacks},n.updateQueue=l;return}n=l.lastBaseUpdate,n===null?l.firstBaseUpdate=i:n.next=i,l.lastBaseUpdate=i}var Hd=!1;function wl(){if(Hd){var n=ps;if(n!==null)throw n}}function El(n,i,l,u){Hd=!1;var b=n.updateQueue;Ui=!1;var v=b.firstBaseUpdate,A=b.lastBaseUpdate,F=b.shared.pending;if(F!==null){b.shared.pending=null;var ne=F,le=ne.next;ne.next=null,A===null?v=le:A.next=le,A=ne;var he=n.alternate;he!==null&&(he=he.updateQueue,F=he.lastBaseUpdate,F!==A&&(F===null?he.firstBaseUpdate=le:F.next=le,he.lastBaseUpdate=ne))}if(v!==null){var ge=b.baseState;A=0,he=le=ne=null,F=v;do{var oe=F.lane&-536870913,de=oe!==F.lane;if(de?(it&oe)===oe:(u&oe)===oe){oe!==0&&oe===ms&&(Hd=!0),he!==null&&(he=he.next={lane:0,tag:F.tag,payload:F.payload,callback:null,next:null});e:{var Te=n,He=F;oe=i;var Nt=l;switch(He.tag){case 1:if(Te=He.payload,typeof Te=="function"){ge=Te.call(Nt,ge,oe);break e}ge=Te;break e;case 3:Te.flags=Te.flags&-65537|128;case 0:if(Te=He.payload,oe=typeof Te=="function"?Te.call(Nt,ge,oe):Te,oe==null)break e;ge=p({},ge,oe);break e;case 2:Ui=!0}}oe=F.callback,oe!==null&&(n.flags|=64,de&&(n.flags|=8192),de=b.callbacks,de===null?b.callbacks=[oe]:de.push(oe))}else de={lane:oe,tag:F.tag,payload:F.payload,callback:F.callback,next:null},he===null?(le=he=de,ne=ge):he=he.next=de,A|=oe;if(F=F.next,F===null){if(F=b.shared.pending,F===null)break;de=F,F=de.next,de.next=null,b.lastBaseUpdate=de,b.shared.pending=null}}while(!0);he===null&&(ne=ge),b.baseState=ne,b.firstBaseUpdate=le,b.lastBaseUpdate=he,v===null&&(b.shared.lanes=0),Vi|=A,n.lanes=A,n.memoizedState=ge}}function lb(n,i){if(typeof n!="function")throw Error(a(191,n));n.call(i)}function ob(n,i){var l=n.callbacks;if(l!==null)for(n.callbacks=null,n=0;nv?v:8;var A=O.T,F={};O.T=F,sf(n,!1,i,l);try{var ne=b(),le=O.S;if(le!==null&&le(F,ne),ne!==null&&typeof ne=="object"&&typeof ne.then=="function"){var he=l2(ne,u);kl(n,i,he,tr(n))}else kl(n,i,u,tr(n))}catch(ge){kl(n,i,{then:function(){},status:"rejected",reason:ge},tr())}finally{H.p=v,A!==null&&F.types!==null&&(A.types=F.types),O.T=A}}function h2(){}function rf(n,i,l,u){if(n.tag!==5)throw Error(a(476));var b=Hb(n).queue;Ub(n,b,i,X,l===null?h2:function(){return $b(n),l(u)})}function Hb(n){var i=n.memoizedState;if(i!==null)return i;i={memoizedState:X,baseState:X,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:li,lastRenderedState:X},next:null};var l={};return i.next={memoizedState:l,baseState:l,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:li,lastRenderedState:l},next:null},n.memoizedState=i,n=n.alternate,n!==null&&(n.memoizedState=i),i}function $b(n){var i=Hb(n);i.next===null&&(i=n.alternate.memoizedState),kl(n,i.next.queue,{},tr())}function af(){return yn(Pl)}function qb(){return Qt().memoizedState}function Pb(){return Qt().memoizedState}function m2(n){for(var i=n.return;i!==null;){switch(i.tag){case 24:case 3:var l=tr();n=Hi(l);var u=$i(i,n,l);u!==null&&(Pn(u,i,l),_l(u,i,l)),i={cache:jd()},n.payload=i;return}i=i.return}}function p2(n,i,l){var u=tr();l={lane:u,revertLane:0,gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null},gc(n)?Gb(i,l):(l=wd(n,i,l,u),l!==null&&(Pn(l,n,u),Vb(l,i,u)))}function Fb(n,i,l){var u=tr();kl(n,i,l,u)}function kl(n,i,l,u){var b={lane:u,revertLane:0,gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null};if(gc(n))Gb(i,b);else{var v=n.alternate;if(n.lanes===0&&(v===null||v.lanes===0)&&(v=i.lastRenderedReducer,v!==null))try{var A=i.lastRenderedState,F=v(A,l);if(b.hasEagerState=!0,b.eagerState=F,Kn(F,A))return Zo(n,i,b,0),kt===null&&Ko(),!1}catch{}finally{}if(l=wd(n,i,b,u),l!==null)return Pn(l,n,u),Vb(l,i,u),!0}return!1}function sf(n,i,l,u){if(u={lane:2,revertLane:Bf(),gesture:null,action:u,hasEagerState:!1,eagerState:null,next:null},gc(n)){if(i)throw Error(a(479))}else i=wd(n,l,u,2),i!==null&&Pn(i,n,2)}function gc(n){var i=n.alternate;return n===Xe||i!==null&&i===Xe}function Gb(n,i){ys=cc=!0;var l=n.pending;l===null?i.next=i:(i.next=l.next,l.next=i),n.pending=i}function Vb(n,i,l){if((l&4194048)!==0){var u=i.lanes;u&=n.pendingLanes,l|=u,i.lanes=l,Ue(n,l)}}var Cl={readContext:yn,use:fc,useCallback:Gt,useContext:Gt,useEffect:Gt,useImperativeHandle:Gt,useLayoutEffect:Gt,useInsertionEffect:Gt,useMemo:Gt,useReducer:Gt,useRef:Gt,useState:Gt,useDebugValue:Gt,useDeferredValue:Gt,useTransition:Gt,useSyncExternalStore:Gt,useId:Gt,useHostTransitionStatus:Gt,useFormState:Gt,useActionState:Gt,useOptimistic:Gt,useMemoCache:Gt,useCacheRefresh:Gt};Cl.useEffectEvent=Gt;var Yb={readContext:yn,use:fc,useCallback:function(n,i){return Dn().memoizedState=[n,i===void 0?null:i],n},useContext:yn,useEffect:Mb,useImperativeHandle:function(n,i,l){l=l!=null?l.concat([n]):null,mc(4194308,4,Db.bind(null,i,n),l)},useLayoutEffect:function(n,i){return mc(4194308,4,n,i)},useInsertionEffect:function(n,i){mc(4,2,n,i)},useMemo:function(n,i){var l=Dn();i=i===void 0?null:i;var u=n();if(Oa){Wt(!0);try{n()}finally{Wt(!1)}}return l.memoizedState=[u,i],u},useReducer:function(n,i,l){var u=Dn();if(l!==void 0){var b=l(i);if(Oa){Wt(!0);try{l(i)}finally{Wt(!1)}}}else b=i;return u.memoizedState=u.baseState=b,n={pending:null,lanes:0,dispatch:null,lastRenderedReducer:n,lastRenderedState:b},u.queue=n,n=n.dispatch=p2.bind(null,Xe,n),[u.memoizedState,n]},useRef:function(n){var i=Dn();return n={current:n},i.memoizedState=n},useState:function(n){n=Wd(n);var i=n.queue,l=Fb.bind(null,Xe,i);return i.dispatch=l,[n.memoizedState,l]},useDebugValue:tf,useDeferredValue:function(n,i){var l=Dn();return nf(l,n,i)},useTransition:function(){var n=Wd(!1);return n=Ub.bind(null,Xe,n.queue,!0,!1),Dn().memoizedState=n,[!1,n]},useSyncExternalStore:function(n,i,l){var u=Xe,b=Dn();if(lt){if(l===void 0)throw Error(a(407));l=l()}else{if(l=i(),kt===null)throw Error(a(349));(it&127)!==0||mb(u,i,l)}b.memoizedState=l;var v={value:l,getSnapshot:i};return b.queue=v,Mb(gb.bind(null,u,v,n),[n]),u.flags|=2048,_s(9,{destroy:void 0},pb.bind(null,u,v,l,i),null),l},useId:function(){var n=Dn(),i=kt.identifierPrefix;if(lt){var l=$r,u=Hr;l=(u&~(1<<32-ut(u)-1)).toString(32)+l,i="_"+i+"R_"+l,l=uc++,0<\/script>",v=v.removeChild(v.firstChild);break;case"select":v=typeof u.is=="string"?A.createElement("select",{is:u.is}):A.createElement("select"),u.multiple?v.multiple=!0:u.size&&(v.size=u.size);break;default:v=typeof u.is=="string"?A.createElement(b,{is:u.is}):A.createElement(b)}}v[Ut]=i,v[mn]=u;e:for(A=i.child;A!==null;){if(A.tag===5||A.tag===6)v.appendChild(A.stateNode);else if(A.tag!==4&&A.tag!==27&&A.child!==null){A.child.return=A,A=A.child;continue}if(A===i)break e;for(;A.sibling===null;){if(A.return===null||A.return===i)break e;A=A.return}A.sibling.return=A.return,A=A.sibling}i.stateNode=v;e:switch(_n(v,b,u),b){case"button":case"input":case"select":case"textarea":u=!!u.autoFocus;break e;case"img":u=!0;break e;default:u=!1}u&&ci(i)}}return Dt(i),vf(i,i.type,n===null?null:n.memoizedProps,i.pendingProps,l),null;case 6:if(n&&i.stateNode!=null)n.memoizedProps!==u&&ci(i);else{if(typeof u!="string"&&i.stateNode===null)throw Error(a(166));if(n=Q.current,fs(i)){if(n=i.stateNode,l=i.memoizedProps,u=null,b=xn,b!==null)switch(b.tag){case 27:case 5:u=b.memoizedProps}n[Ut]=i,n=!!(n.nodeValue===l||u!==null&&u.suppressHydrationWarning===!0||f0(n.nodeValue,l)),n||Ii(i,!0)}else n=zc(n).createTextNode(u),n[Ut]=i,i.stateNode=n}return Dt(i),null;case 31:if(l=i.memoizedState,n===null||n.memoizedState!==null){if(u=fs(i),l!==null){if(n===null){if(!u)throw Error(a(318));if(n=i.memoizedState,n=n!==null?n.dehydrated:null,!n)throw Error(a(557));n[Ut]=i}else Na(),(i.flags&128)===0&&(i.memoizedState=null),i.flags|=4;Dt(i),n=!1}else l=Ad(),n!==null&&n.memoizedState!==null&&(n.memoizedState.hydrationErrors=l),n=!0;if(!n)return i.flags&256?(Wn(i),i):(Wn(i),null);if((i.flags&128)!==0)throw Error(a(558))}return Dt(i),null;case 13:if(u=i.memoizedState,n===null||n.memoizedState!==null&&n.memoizedState.dehydrated!==null){if(b=fs(i),u!==null&&u.dehydrated!==null){if(n===null){if(!b)throw Error(a(318));if(b=i.memoizedState,b=b!==null?b.dehydrated:null,!b)throw Error(a(317));b[Ut]=i}else Na(),(i.flags&128)===0&&(i.memoizedState=null),i.flags|=4;Dt(i),b=!1}else b=Ad(),n!==null&&n.memoizedState!==null&&(n.memoizedState.hydrationErrors=b),b=!0;if(!b)return i.flags&256?(Wn(i),i):(Wn(i),null)}return Wn(i),(i.flags&128)!==0?(i.lanes=l,i):(l=u!==null,n=n!==null&&n.memoizedState!==null,l&&(u=i.child,b=null,u.alternate!==null&&u.alternate.memoizedState!==null&&u.alternate.memoizedState.cachePool!==null&&(b=u.alternate.memoizedState.cachePool.pool),v=null,u.memoizedState!==null&&u.memoizedState.cachePool!==null&&(v=u.memoizedState.cachePool.pool),v!==b&&(u.flags|=2048)),l!==n&&l&&(i.child.flags|=8192),_c(i,i.updateQueue),Dt(i),null);case 4:return te(),n===null&&qf(i.stateNode.containerInfo),Dt(i),null;case 10:return ai(i.type),Dt(i),null;case 19:if(Y(Zt),u=i.memoizedState,u===null)return Dt(i),null;if(b=(i.flags&128)!==0,v=u.rendering,v===null)if(b)Al(u,!1);else{if(Vt!==0||n!==null&&(n.flags&128)!==0)for(n=i.child;n!==null;){if(v=oc(n),v!==null){for(i.flags|=128,Al(u,!1),n=v.updateQueue,i.updateQueue=n,_c(i,n),i.subtreeFlags=0,n=l,l=i.child;l!==null;)Fg(l,n),l=l.sibling;return L(Zt,Zt.current&1|2),lt&&ri(i,u.treeForkCount),i.child}n=n.sibling}u.tail!==null&&ct()>kc&&(i.flags|=128,b=!0,Al(u,!1),i.lanes=4194304)}else{if(!b)if(n=oc(v),n!==null){if(i.flags|=128,b=!0,n=n.updateQueue,i.updateQueue=n,_c(i,n),Al(u,!0),u.tail===null&&u.tailMode==="hidden"&&!v.alternate&&!lt)return Dt(i),null}else 2*ct()-u.renderingStartTime>kc&&l!==536870912&&(i.flags|=128,b=!0,Al(u,!1),i.lanes=4194304);u.isBackwards?(v.sibling=i.child,i.child=v):(n=u.last,n!==null?n.sibling=v:i.child=v,u.last=v)}return u.tail!==null?(n=u.tail,u.rendering=n,u.tail=n.sibling,u.renderingStartTime=ct(),n.sibling=null,l=Zt.current,L(Zt,b?l&1|2:l&1),lt&&ri(i,u.treeForkCount),n):(Dt(i),null);case 22:case 23:return Wn(i),qd(),u=i.memoizedState!==null,n!==null?n.memoizedState!==null!==u&&(i.flags|=8192):u&&(i.flags|=8192),u?(l&536870912)!==0&&(i.flags&128)===0&&(Dt(i),i.subtreeFlags&6&&(i.flags|=8192)):Dt(i),l=i.updateQueue,l!==null&&_c(i,l.retryQueue),l=null,n!==null&&n.memoizedState!==null&&n.memoizedState.cachePool!==null&&(l=n.memoizedState.cachePool.pool),u=null,i.memoizedState!==null&&i.memoizedState.cachePool!==null&&(u=i.memoizedState.cachePool.pool),u!==l&&(i.flags|=2048),n!==null&&Y(Ca),null;case 24:return l=null,n!==null&&(l=n.memoizedState.cache),i.memoizedState.cache!==l&&(i.flags|=2048),ai(en),Dt(i),null;case 25:return null;case 30:return null}throw Error(a(156,i.tag))}function v2(n,i){switch(Cd(i),i.tag){case 1:return n=i.flags,n&65536?(i.flags=n&-65537|128,i):null;case 3:return ai(en),te(),n=i.flags,(n&65536)!==0&&(n&128)===0?(i.flags=n&-65537|128,i):null;case 26:case 27:case 5:return fe(i),null;case 31:if(i.memoizedState!==null){if(Wn(i),i.alternate===null)throw Error(a(340));Na()}return n=i.flags,n&65536?(i.flags=n&-65537|128,i):null;case 13:if(Wn(i),n=i.memoizedState,n!==null&&n.dehydrated!==null){if(i.alternate===null)throw Error(a(340));Na()}return n=i.flags,n&65536?(i.flags=n&-65537|128,i):null;case 19:return Y(Zt),null;case 4:return te(),null;case 10:return ai(i.type),null;case 22:case 23:return Wn(i),qd(),n!==null&&Y(Ca),n=i.flags,n&65536?(i.flags=n&-65537|128,i):null;case 24:return ai(en),null;case 25:return null;default:return null}}function bx(n,i){switch(Cd(i),i.tag){case 3:ai(en),te();break;case 26:case 27:case 5:fe(i);break;case 4:te();break;case 31:i.memoizedState!==null&&Wn(i);break;case 13:Wn(i);break;case 19:Y(Zt);break;case 10:ai(i.type);break;case 22:case 23:Wn(i),qd(),n!==null&&Y(Ca);break;case 24:ai(en)}}function Ml(n,i){try{var l=i.updateQueue,u=l!==null?l.lastEffect:null;if(u!==null){var b=u.next;l=b;do{if((l.tag&n)===n){u=void 0;var v=l.create,A=l.inst;u=v(),A.destroy=u}l=l.next}while(l!==b)}}catch(F){yt(i,i.return,F)}}function Fi(n,i,l){try{var u=i.updateQueue,b=u!==null?u.lastEffect:null;if(b!==null){var v=b.next;u=v;do{if((u.tag&n)===n){var A=u.inst,F=A.destroy;if(F!==void 0){A.destroy=void 0,b=i;var ne=l,le=F;try{le()}catch(he){yt(b,ne,he)}}}u=u.next}while(u!==v)}}catch(he){yt(i,i.return,he)}}function xx(n){var i=n.updateQueue;if(i!==null){var l=n.stateNode;try{ob(i,l)}catch(u){yt(n,n.return,u)}}}function yx(n,i,l){l.props=Ra(n.type,n.memoizedProps),l.state=n.memoizedState;try{l.componentWillUnmount()}catch(u){yt(n,i,u)}}function Ol(n,i){try{var l=n.ref;if(l!==null){switch(n.tag){case 26:case 27:case 5:var u=n.stateNode;break;case 30:u=n.stateNode;break;default:u=n.stateNode}typeof l=="function"?n.refCleanup=l(u):l.current=u}}catch(b){yt(n,i,b)}}function qr(n,i){var l=n.ref,u=n.refCleanup;if(l!==null)if(typeof u=="function")try{u()}catch(b){yt(n,i,b)}finally{n.refCleanup=null,n=n.alternate,n!=null&&(n.refCleanup=null)}else if(typeof l=="function")try{l(null)}catch(b){yt(n,i,b)}else l.current=null}function vx(n){var i=n.type,l=n.memoizedProps,u=n.stateNode;try{e:switch(i){case"button":case"input":case"select":case"textarea":l.autoFocus&&u.focus();break e;case"img":l.src?u.src=l.src:l.srcSet&&(u.srcset=l.srcSet)}}catch(b){yt(n,n.return,b)}}function _f(n,i,l){try{var u=n.stateNode;q2(u,n.type,l,i),u[mn]=i}catch(b){yt(n,n.return,b)}}function _x(n){return n.tag===5||n.tag===3||n.tag===26||n.tag===27&&Qi(n.type)||n.tag===4}function wf(n){e:for(;;){for(;n.sibling===null;){if(n.return===null||_x(n.return))return null;n=n.return}for(n.sibling.return=n.return,n=n.sibling;n.tag!==5&&n.tag!==6&&n.tag!==18;){if(n.tag===27&&Qi(n.type)||n.flags&2||n.child===null||n.tag===4)continue e;n.child.return=n,n=n.child}if(!(n.flags&2))return n.stateNode}}function Ef(n,i,l){var u=n.tag;if(u===5||u===6)n=n.stateNode,i?(l.nodeType===9?l.body:l.nodeName==="HTML"?l.ownerDocument.body:l).insertBefore(n,i):(i=l.nodeType===9?l.body:l.nodeName==="HTML"?l.ownerDocument.body:l,i.appendChild(n),l=l._reactRootContainer,l!=null||i.onclick!==null||(i.onclick=_e));else if(u!==4&&(u===27&&Qi(n.type)&&(l=n.stateNode,i=null),n=n.child,n!==null))for(Ef(n,i,l),n=n.sibling;n!==null;)Ef(n,i,l),n=n.sibling}function wc(n,i,l){var u=n.tag;if(u===5||u===6)n=n.stateNode,i?l.insertBefore(n,i):l.appendChild(n);else if(u!==4&&(u===27&&Qi(n.type)&&(l=n.stateNode),n=n.child,n!==null))for(wc(n,i,l),n=n.sibling;n!==null;)wc(n,i,l),n=n.sibling}function wx(n){var i=n.stateNode,l=n.memoizedProps;try{for(var u=n.type,b=i.attributes;b.length;)i.removeAttributeNode(b[0]);_n(i,u,l),i[Ut]=n,i[mn]=l}catch(v){yt(n,n.return,v)}}var ui=!1,rn=!1,Nf=!1,Ex=typeof WeakSet=="function"?WeakSet:Set,gn=null;function _2(n,i){if(n=n.containerInfo,Gf=Pc,n=Lg(n),gd(n)){if("selectionStart"in n)var l={start:n.selectionStart,end:n.selectionEnd};else e:{l=(l=n.ownerDocument)&&l.defaultView||window;var u=l.getSelection&&l.getSelection();if(u&&u.rangeCount!==0){l=u.anchorNode;var b=u.anchorOffset,v=u.focusNode;u=u.focusOffset;try{l.nodeType,v.nodeType}catch{l=null;break e}var A=0,F=-1,ne=-1,le=0,he=0,ge=n,oe=null;t:for(;;){for(var de;ge!==l||b!==0&&ge.nodeType!==3||(F=A+b),ge!==v||u!==0&&ge.nodeType!==3||(ne=A+u),ge.nodeType===3&&(A+=ge.nodeValue.length),(de=ge.firstChild)!==null;)oe=ge,ge=de;for(;;){if(ge===n)break t;if(oe===l&&++le===b&&(F=A),oe===v&&++he===u&&(ne=A),(de=ge.nextSibling)!==null)break;ge=oe,oe=ge.parentNode}ge=de}l=F===-1||ne===-1?null:{start:F,end:ne}}else l=null}l=l||{start:0,end:0}}else l=null;for(Vf={focusedElem:n,selectionRange:l},Pc=!1,gn=i;gn!==null;)if(i=gn,n=i.child,(i.subtreeFlags&1028)!==0&&n!==null)n.return=i,gn=n;else for(;gn!==null;){switch(i=gn,v=i.alternate,n=i.flags,i.tag){case 0:if((n&4)!==0&&(n=i.updateQueue,n=n!==null?n.events:null,n!==null))for(l=0;l title"))),_n(v,u,l),v[Ut]=n,Ft(v),u=v;break e;case"link":var A=A0("link","href",b).get(u+(l.href||""));if(A){for(var F=0;FNt&&(A=Nt,Nt=He,He=A);var ae=jg(F,He),ie=jg(F,Nt);if(ae&&ie&&(de.rangeCount!==1||de.anchorNode!==ae.node||de.anchorOffset!==ae.offset||de.focusNode!==ie.node||de.focusOffset!==ie.offset)){var se=ge.createRange();se.setStart(ae.node,ae.offset),de.removeAllRanges(),He>Nt?(de.addRange(se),de.extend(ie.node,ie.offset)):(se.setEnd(ie.node,ie.offset),de.addRange(se))}}}}for(ge=[],de=F;de=de.parentNode;)de.nodeType===1&&ge.push({element:de,left:de.scrollLeft,top:de.scrollTop});for(typeof F.focus=="function"&&F.focus(),F=0;Fl?32:l,O.T=null,l=Of,Of=null;var v=Xi,A=pi;if(dn=0,ks=Xi=null,pi=0,(pt&6)!==0)throw Error(a(331));var F=pt;if(pt|=4,Dx(v.current),Ox(v,v.current,A,l),pt=F,Il(0,!1),At&&typeof At.onPostCommitFiberRoot=="function")try{At.onPostCommitFiberRoot(Kt,v)}catch{}return!0}finally{H.p=b,O.T=u,Wx(n,i)}}function e0(n,i,l){i=ur(l,i),i=uf(n.stateNode,i,2),n=$i(n,i,2),n!==null&&(gt(n,2),Pr(n))}function yt(n,i,l){if(n.tag===3)e0(n,n,l);else for(;i!==null;){if(i.tag===3){e0(i,n,l);break}else if(i.tag===1){var u=i.stateNode;if(typeof i.type.getDerivedStateFromError=="function"||typeof u.componentDidCatch=="function"&&(Yi===null||!Yi.has(u))){n=ur(l,n),l=tx(2),u=$i(i,l,2),u!==null&&(nx(l,u,i,n),gt(u,2),Pr(u));break}}i=i.return}}function Lf(n,i,l){var u=n.pingCache;if(u===null){u=n.pingCache=new N2;var b=new Set;u.set(i,b)}else b=u.get(i),b===void 0&&(b=new Set,u.set(i,b));b.has(l)||(Cf=!0,b.add(l),n=A2.bind(null,n,i,l),i.then(n,n))}function A2(n,i,l){var u=n.pingCache;u!==null&&u.delete(i),n.pingedLanes|=n.suspendedLanes&l,n.warmLanes&=~l,kt===n&&(it&l)===l&&(Vt===4||Vt===3&&(it&62914560)===it&&300>ct()-Sc?(pt&2)===0&&Cs(n,0):Tf|=l,Ss===it&&(Ss=0)),Pr(n)}function t0(n,i){i===0&&(i=Pe()),n=wa(n,i),n!==null&&(gt(n,i),Pr(n))}function M2(n){var i=n.memoizedState,l=0;i!==null&&(l=i.retryLane),t0(n,l)}function O2(n,i){var l=0;switch(n.tag){case 31:case 13:var u=n.stateNode,b=n.memoizedState;b!==null&&(l=b.retryLane);break;case 19:u=n.stateNode;break;case 22:u=n.stateNode._retryCache;break;default:throw Error(a(314))}u!==null&&u.delete(i),t0(n,l)}function R2(n,i){return Pt(n,i)}var Rc=null,As=null,zf=!1,jc=!1,If=!1,Zi=0;function Pr(n){n!==As&&n.next===null&&(As===null?Rc=As=n:As=As.next=n),jc=!0,zf||(zf=!0,D2())}function Il(n,i){if(!If&&jc){If=!0;do for(var l=!1,u=Rc;u!==null;){if(n!==0){var b=u.pendingLanes;if(b===0)var v=0;else{var A=u.suspendedLanes,F=u.pingedLanes;v=(1<<31-ut(42|n)+1)-1,v&=b&~(A&~F),v=v&201326741?v&201326741|1:v?v|2:0}v!==0&&(l=!0,a0(u,v))}else v=it,v=re(u,u===kt?v:0,u.cancelPendingCommit!==null||u.timeoutHandle!==-1),(v&3)===0||me(u,v)||(l=!0,a0(u,v));u=u.next}while(l);If=!1}}function j2(){n0()}function n0(){jc=zf=!1;var n=0;Zi!==0&&F2()&&(n=Zi);for(var i=ct(),l=null,u=Rc;u!==null;){var b=u.next,v=r0(u,i);v===0?(u.next=null,l===null?Rc=b:l.next=b,b===null&&(As=l)):(l=u,(n!==0||(v&3)!==0)&&(jc=!0)),u=b}dn!==0&&dn!==5||Il(n),Zi!==0&&(Zi=0)}function r0(n,i){for(var l=n.suspendedLanes,u=n.pingedLanes,b=n.expirationTimes,v=n.pendingLanes&-62914561;0F)break;var he=ne.transferSize,ge=ne.initiatorType;he&&h0(ge)&&(ne=ne.responseEnd,A+=he*(ne"u"?null:document;function S0(n,i,l){var u=Ms;if(u&&typeof i=="string"&&i){var b=kn(i);b='link[rel="'+n+'"][href="'+b+'"]',typeof l=="string"&&(b+='[crossorigin="'+l+'"]'),N0.has(b)||(N0.add(b),n={rel:n,crossOrigin:l,href:i},u.querySelector(b)===null&&(i=u.createElement("link"),_n(i,"link",n),Ft(i),u.head.appendChild(i)))}}function J2(n){gi.D(n),S0("dns-prefetch",n,null)}function ek(n,i){gi.C(n,i),S0("preconnect",n,i)}function tk(n,i,l){gi.L(n,i,l);var u=Ms;if(u&&n&&i){var b='link[rel="preload"][as="'+kn(i)+'"]';i==="image"&&l&&l.imageSrcSet?(b+='[imagesrcset="'+kn(l.imageSrcSet)+'"]',typeof l.imageSizes=="string"&&(b+='[imagesizes="'+kn(l.imageSizes)+'"]')):b+='[href="'+kn(n)+'"]';var v=b;switch(i){case"style":v=Os(n);break;case"script":v=Rs(n)}gr.has(v)||(n=p({rel:"preload",href:i==="image"&&l&&l.imageSrcSet?void 0:n,as:i},l),gr.set(v,n),u.querySelector(b)!==null||i==="style"&&u.querySelector($l(v))||i==="script"&&u.querySelector(ql(v))||(i=u.createElement("link"),_n(i,"link",n),Ft(i),u.head.appendChild(i)))}}function nk(n,i){gi.m(n,i);var l=Ms;if(l&&n){var u=i&&typeof i.as=="string"?i.as:"script",b='link[rel="modulepreload"][as="'+kn(u)+'"][href="'+kn(n)+'"]',v=b;switch(u){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":v=Rs(n)}if(!gr.has(v)&&(n=p({rel:"modulepreload",href:n},i),gr.set(v,n),l.querySelector(b)===null)){switch(u){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(l.querySelector(ql(v)))return}u=l.createElement("link"),_n(u,"link",n),Ft(u),l.head.appendChild(u)}}}function rk(n,i,l){gi.S(n,i,l);var u=Ms;if(u&&n){var b=Br(u).hoistableStyles,v=Os(n);i=i||"default";var A=b.get(v);if(!A){var F={loading:0,preload:null};if(A=u.querySelector($l(v)))F.loading=5;else{n=p({rel:"stylesheet",href:n,"data-precedence":i},l),(l=gr.get(v))&&Jf(n,l);var ne=A=u.createElement("link");Ft(ne),_n(ne,"link",n),ne._p=new Promise(function(le,he){ne.onload=le,ne.onerror=he}),ne.addEventListener("load",function(){F.loading|=1}),ne.addEventListener("error",function(){F.loading|=2}),F.loading|=4,Bc(A,i,u)}A={type:"stylesheet",instance:A,count:1,state:F},b.set(v,A)}}}function ik(n,i){gi.X(n,i);var l=Ms;if(l&&n){var u=Br(l).hoistableScripts,b=Rs(n),v=u.get(b);v||(v=l.querySelector(ql(b)),v||(n=p({src:n,async:!0},i),(i=gr.get(b))&&eh(n,i),v=l.createElement("script"),Ft(v),_n(v,"link",n),l.head.appendChild(v)),v={type:"script",instance:v,count:1,state:null},u.set(b,v))}}function ak(n,i){gi.M(n,i);var l=Ms;if(l&&n){var u=Br(l).hoistableScripts,b=Rs(n),v=u.get(b);v||(v=l.querySelector(ql(b)),v||(n=p({src:n,async:!0,type:"module"},i),(i=gr.get(b))&&eh(n,i),v=l.createElement("script"),Ft(v),_n(v,"link",n),l.head.appendChild(v)),v={type:"script",instance:v,count:1,state:null},u.set(b,v))}}function k0(n,i,l,u){var b=(b=Q.current)?Ic(b):null;if(!b)throw Error(a(446));switch(n){case"meta":case"title":return null;case"style":return typeof l.precedence=="string"&&typeof l.href=="string"?(i=Os(l.href),l=Br(b).hoistableStyles,u=l.get(i),u||(u={type:"style",instance:null,count:0,state:null},l.set(i,u)),u):{type:"void",instance:null,count:0,state:null};case"link":if(l.rel==="stylesheet"&&typeof l.href=="string"&&typeof l.precedence=="string"){n=Os(l.href);var v=Br(b).hoistableStyles,A=v.get(n);if(A||(b=b.ownerDocument||b,A={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},v.set(n,A),(v=b.querySelector($l(n)))&&!v._p&&(A.instance=v,A.state.loading=5),gr.has(n)||(l={rel:"preload",as:"style",href:l.href,crossOrigin:l.crossOrigin,integrity:l.integrity,media:l.media,hrefLang:l.hrefLang,referrerPolicy:l.referrerPolicy},gr.set(n,l),v||sk(b,n,l,A.state))),i&&u===null)throw Error(a(528,""));return A}if(i&&u!==null)throw Error(a(529,""));return null;case"script":return i=l.async,l=l.src,typeof l=="string"&&i&&typeof i!="function"&&typeof i!="symbol"?(i=Rs(l),l=Br(b).hoistableScripts,u=l.get(i),u||(u={type:"script",instance:null,count:0,state:null},l.set(i,u)),u):{type:"void",instance:null,count:0,state:null};default:throw Error(a(444,n))}}function Os(n){return'href="'+kn(n)+'"'}function $l(n){return'link[rel="stylesheet"]['+n+"]"}function C0(n){return p({},n,{"data-precedence":n.precedence,precedence:null})}function sk(n,i,l,u){n.querySelector('link[rel="preload"][as="style"]['+i+"]")?u.loading=1:(i=n.createElement("link"),u.preload=i,i.addEventListener("load",function(){return u.loading|=1}),i.addEventListener("error",function(){return u.loading|=2}),_n(i,"link",l),Ft(i),n.head.appendChild(i))}function Rs(n){return'[src="'+kn(n)+'"]'}function ql(n){return"script[async]"+n}function T0(n,i,l){if(i.count++,i.instance===null)switch(i.type){case"style":var u=n.querySelector('style[data-href~="'+kn(l.href)+'"]');if(u)return i.instance=u,Ft(u),u;var b=p({},l,{"data-href":l.href,"data-precedence":l.precedence,href:null,precedence:null});return u=(n.ownerDocument||n).createElement("style"),Ft(u),_n(u,"style",b),Bc(u,l.precedence,n),i.instance=u;case"stylesheet":b=Os(l.href);var v=n.querySelector($l(b));if(v)return i.state.loading|=4,i.instance=v,Ft(v),v;u=C0(l),(b=gr.get(b))&&Jf(u,b),v=(n.ownerDocument||n).createElement("link"),Ft(v);var A=v;return A._p=new Promise(function(F,ne){A.onload=F,A.onerror=ne}),_n(v,"link",u),i.state.loading|=4,Bc(v,l.precedence,n),i.instance=v;case"script":return v=Rs(l.src),(b=n.querySelector(ql(v)))?(i.instance=b,Ft(b),b):(u=l,(b=gr.get(v))&&(u=p({},l),eh(u,b)),n=n.ownerDocument||n,b=n.createElement("script"),Ft(b),_n(b,"link",u),n.head.appendChild(b),i.instance=b);case"void":return null;default:throw Error(a(443,i.type))}else i.type==="stylesheet"&&(i.state.loading&4)===0&&(u=i.instance,i.state.loading|=4,Bc(u,l.precedence,n));return i.instance}function Bc(n,i,l){for(var u=l.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),b=u.length?u[u.length-1]:null,v=b,A=0;A title"):null)}function lk(n,i,l){if(l===1||i.itemProp!=null)return!1;switch(n){case"meta":case"title":return!0;case"style":if(typeof i.precedence!="string"||typeof i.href!="string"||i.href==="")break;return!0;case"link":if(typeof i.rel!="string"||typeof i.href!="string"||i.href===""||i.onLoad||i.onError)break;switch(i.rel){case"stylesheet":return n=i.disabled,typeof i.precedence=="string"&&n==null;default:return!0}case"script":if(i.async&&typeof i.async!="function"&&typeof i.async!="symbol"&&!i.onLoad&&!i.onError&&i.src&&typeof i.src=="string")return!0}return!1}function O0(n){return!(n.type==="stylesheet"&&(n.state.loading&3)===0)}function ok(n,i,l,u){if(l.type==="stylesheet"&&(typeof u.media!="string"||matchMedia(u.media).matches!==!1)&&(l.state.loading&4)===0){if(l.instance===null){var b=Os(u.href),v=i.querySelector($l(b));if(v){i=v._p,i!==null&&typeof i=="object"&&typeof i.then=="function"&&(n.count++,n=Hc.bind(n),i.then(n,n)),l.state.loading|=4,l.instance=v,Ft(v);return}v=i.ownerDocument||i,u=C0(u),(b=gr.get(b))&&Jf(u,b),v=v.createElement("link"),Ft(v);var A=v;A._p=new Promise(function(F,ne){A.onload=F,A.onerror=ne}),_n(v,"link",u),l.instance=v}n.stylesheets===null&&(n.stylesheets=new Map),n.stylesheets.set(l,i),(i=l.state.preload)&&(l.state.loading&3)===0&&(n.count++,l=Hc.bind(n),i.addEventListener("load",l),i.addEventListener("error",l))}}var th=0;function ck(n,i){return n.stylesheets&&n.count===0&&qc(n,n.stylesheets),0th?50:800)+i);return n.unsuspend=l,function(){n.unsuspend=null,clearTimeout(u),clearTimeout(b)}}:null}function Hc(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)qc(this,this.stylesheets);else if(this.unsuspend){var n=this.unsuspend;this.unsuspend=null,n()}}}var $c=null;function qc(n,i){n.stylesheets=null,n.unsuspend!==null&&(n.count++,$c=new Map,i.forEach(uk,n),$c=null,Hc.call(n))}function uk(n,i){if(!(i.state.loading&4)){var l=$c.get(n);if(l)var u=l.get(null);else{l=new Map,$c.set(n,l);for(var b=n.querySelectorAll("link[data-precedence],style[data-precedence]"),v=0;v"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(t){console.error(t)}}return e(),uh.exports=Sk(),uh.exports}var Ck=kk();/** +`+u.stack}}var Yt=Object.prototype.hasOwnProperty,Pt=e.unstable_scheduleCallback,Xt=e.unstable_cancelCallback,Yn=e.unstable_shouldYield,En=e.unstable_requestPaint,ct=e.unstable_now,It=e.unstable_getCurrentPriorityLevel,ue=e.unstable_ImmediatePriority,xe=e.unstable_UserBlockingPriority,Oe=e.unstable_NormalPriority,Fe=e.unstable_LowPriority,Ze=e.unstable_IdlePriority,on=e.log,Nn=e.unstable_setDisableYieldValue,Kt=null,At=null;function Wt(n){if(typeof on=="function"&&Nn(n),At&&typeof At.setStrictMode=="function")try{At.setStrictMode(Kt,n)}catch{}}var ut=Math.clz32?Math.clz32:Ni,In=Math.log,cn=Math.LN2;function Ni(n){return n>>>=0,n===0?32:31-(In(n)/cn|0)|0}var nt=256,Xn=262144,On=4194304;function hn(n){var i=n&42;if(i!==0)return i;switch(n&-n){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return n&261888;case 262144:case 524288:case 1048576:case 2097152:return n&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return n&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return n}}function re(n,i,l){var u=n.pendingLanes;if(u===0)return 0;var b=0,v=n.suspendedLanes,A=n.pingedLanes;n=n.warmLanes;var F=u&134217727;return F!==0?(u=F&~v,u!==0?b=hn(u):(A&=F,A!==0?b=hn(A):l||(l=F&~n,l!==0&&(b=hn(l))))):(F=u&~v,F!==0?b=hn(F):A!==0?b=hn(A):l||(l=u&~n,l!==0&&(b=hn(l)))),b===0?0:i!==0&&i!==b&&(i&v)===0&&(v=b&-b,l=i&-i,v>=l||v===32&&(l&4194048)!==0)?i:b}function me(n,i){return(n.pendingLanes&~(n.suspendedLanes&~n.pingedLanes)&i)===0}function Ee(n,i){switch(n){case 1:case 2:case 4:case 8:case 64:return i+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return i+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Pe(){var n=On;return On<<=1,(On&62914560)===0&&(On=4194304),n}function St(n){for(var i=[],l=0;31>l;l++)i.push(n);return i}function gt(n,i){n.pendingLanes|=i,i!==268435456&&(n.suspendedLanes=0,n.pingedLanes=0,n.warmLanes=0)}function Ae(n,i,l,u,b,v){var A=n.pendingLanes;n.pendingLanes=l,n.suspendedLanes=0,n.pingedLanes=0,n.warmLanes=0,n.expiredLanes&=l,n.entangledLanes&=l,n.errorRecoveryDisabledLanes&=l,n.shellSuspendCounter=0;var F=n.entanglements,ne=n.expirationTimes,le=n.hiddenUpdates;for(l=A&~l;0"u")return null;try{return n.activeElement||n.body}catch{return n.body}}var rs=/[\n"\\]/g;function kn(n){return n.replace(rs,function(i){return"\\"+i.charCodeAt(0).toString(16)+" "})}function xa(n,i,l,u,b,v,A,F){n.name="",A!=null&&typeof A!="function"&&typeof A!="symbol"&&typeof A!="boolean"?n.type=A:n.removeAttribute("type"),i!=null?A==="number"?(i===0&&n.value===""||n.value!=i)&&(n.value=""+_t(i)):n.value!==""+_t(i)&&(n.value=""+_t(i)):A!=="submit"&&A!=="reset"||n.removeAttribute("value"),i!=null?Oi(n,A,_t(i)):l!=null?Oi(n,A,_t(l)):u!=null&&n.removeAttribute("value"),b==null&&v!=null&&(n.defaultChecked=!!v),b!=null&&(n.checked=b&&typeof b!="function"&&typeof b!="symbol"),F!=null&&typeof F!="function"&&typeof F!="symbol"&&typeof F!="boolean"?n.name=""+_t(F):n.removeAttribute("name")}function Er(n,i,l,u,b,v,A,F){if(v!=null&&typeof v!="function"&&typeof v!="symbol"&&typeof v!="boolean"&&(n.type=v),i!=null||l!=null){if(!(v!=="submit"&&v!=="reset"||i!=null)){Ai(n);return}l=l!=null?""+_t(l):"",i=i!=null?""+_t(i):l,F||i===n.value||(n.value=i),n.defaultValue=i}u=u??b,u=typeof u!="function"&&typeof u!="symbol"&&!!u,n.checked=F?n.checked:!!u,n.defaultChecked=!!u,A!=null&&typeof A!="function"&&typeof A!="symbol"&&typeof A!="boolean"&&(n.name=A),Ai(n)}function Oi(n,i,l){i==="number"&&Mi(n.ownerDocument)===n||n.defaultValue===""+l||(n.defaultValue=""+l)}function un(n,i,l,u){if(n=n.options,i){i={};for(var b=0;b"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),ld=!1;if(ti)try{var ll={};Object.defineProperty(ll,"passive",{get:function(){ld=!0}}),window.addEventListener("test",ll,ll),window.removeEventListener("test",ll,ll)}catch{ld=!1}var Di=null,od=null,qo=null;function gg(){if(qo)return qo;var n,i=od,l=i.length,u,b="value"in Di?Di.value:Di.textContent,v=b.length;for(n=0;n=ul),wg=" ",Eg=!1;function Ng(n,i){switch(n){case"keyup":return $S.indexOf(i.keyCode)!==-1;case"keydown":return i.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Sg(n){return n=n.detail,typeof n=="object"&&"data"in n?n.data:null}var as=!1;function PS(n,i){switch(n){case"compositionend":return Sg(i);case"keypress":return i.which!==32?null:(Eg=!0,wg);case"textInput":return n=i.data,n===wg&&Eg?null:n;default:return null}}function FS(n,i){if(as)return n==="compositionend"||!hd&&Ng(n,i)?(n=gg(),qo=od=Di=null,as=!1,n):null;switch(n){case"paste":return null;case"keypress":if(!(i.ctrlKey||i.altKey||i.metaKey)||i.ctrlKey&&i.altKey){if(i.char&&1=i)return{node:l,offset:i-n};n=u}e:{for(;l;){if(l.nextSibling){l=l.nextSibling;break e}l=l.parentNode}l=void 0}l=jg(l)}}function Lg(n,i){return n&&i?n===i?!0:n&&n.nodeType===3?!1:i&&i.nodeType===3?Lg(n,i.parentNode):"contains"in n?n.contains(i):n.compareDocumentPosition?!!(n.compareDocumentPosition(i)&16):!1:!1}function zg(n){n=n!=null&&n.ownerDocument!=null&&n.ownerDocument.defaultView!=null?n.ownerDocument.defaultView:window;for(var i=Mi(n.document);i instanceof n.HTMLIFrameElement;){try{var l=typeof i.contentWindow.location.href=="string"}catch{l=!1}if(l)n=i.contentWindow;else break;i=Mi(n.document)}return i}function gd(n){var i=n&&n.nodeName&&n.nodeName.toLowerCase();return i&&(i==="input"&&(n.type==="text"||n.type==="search"||n.type==="tel"||n.type==="url"||n.type==="password")||i==="textarea"||n.contentEditable==="true")}var WS=ti&&"documentMode"in document&&11>=document.documentMode,ss=null,bd=null,ml=null,xd=!1;function Ig(n,i,l){var u=l.window===l?l.document:l.nodeType===9?l:l.ownerDocument;xd||ss==null||ss!==Mi(u)||(u=ss,"selectionStart"in u&&gd(u)?u={start:u.selectionStart,end:u.selectionEnd}:(u=(u.ownerDocument&&u.ownerDocument.defaultView||window).getSelection(),u={anchorNode:u.anchorNode,anchorOffset:u.anchorOffset,focusNode:u.focusNode,focusOffset:u.focusOffset}),ml&&hl(ml,u)||(ml=u,u=Lc(bd,"onSelect"),0>=A,b-=A,Hr=1<<32-ut(i)+b|l<Ke?(at=je,je=null):at=je.sibling;var mt=oe(ae,je,se[Ke],pe);if(mt===null){je===null&&(je=at);break}n&&je&&mt.alternate===null&&i(ae,je),ie=v(mt,ie,Ke),ht===null?Ie=mt:ht.sibling=mt,ht=mt,je=at}if(Ke===se.length)return l(ae,je),lt&&ri(ae,Ke),Ie;if(je===null){for(;KeKe?(at=je,je=null):at=je.sibling;var na=oe(ae,je,mt.value,pe);if(na===null){je===null&&(je=at);break}n&&je&&na.alternate===null&&i(ae,je),ie=v(na,ie,Ke),ht===null?Ie=na:ht.sibling=na,ht=na,je=at}if(mt.done)return l(ae,je),lt&&ri(ae,Ke),Ie;if(je===null){for(;!mt.done;Ke++,mt=se.next())mt=ge(ae,mt.value,pe),mt!==null&&(ie=v(mt,ie,Ke),ht===null?Ie=mt:ht.sibling=mt,ht=mt);return lt&&ri(ae,Ke),Ie}for(je=u(je);!mt.done;Ke++,mt=se.next())mt=de(je,ae,Ke,mt.value,pe),mt!==null&&(n&&mt.alternate!==null&&je.delete(mt.key===null?Ke:mt.key),ie=v(mt,ie,Ke),ht===null?Ie=mt:ht.sibling=mt,ht=mt);return n&&je.forEach(function(xk){return i(ae,xk)}),lt&&ri(ae,Ke),Ie}function Nt(ae,ie,se,pe){if(typeof se=="object"&&se!==null&&se.type===N&&se.key===null&&(se=se.props.children),typeof se=="object"&&se!==null){switch(se.$$typeof){case x:e:{for(var Ie=se.key;ie!==null;){if(ie.key===Ie){if(Ie=se.type,Ie===N){if(ie.tag===7){l(ae,ie.sibling),pe=b(ie,se.props.children),pe.return=ae,ae=pe;break e}}else if(ie.elementType===Ie||typeof Ie=="object"&&Ie!==null&&Ie.$$typeof===B&&Aa(Ie)===ie.type){l(ae,ie.sibling),pe=b(ie,se.props),vl(pe,se),pe.return=ae,ae=pe;break e}l(ae,ie);break}else i(ae,ie);ie=ie.sibling}se.type===N?(pe=Na(se.props.children,ae.mode,pe,se.key),pe.return=ae,ae=pe):(pe=Wo(se.type,se.key,se.props,null,ae.mode,pe),vl(pe,se),pe.return=ae,ae=pe)}return A(ae);case _:e:{for(Ie=se.key;ie!==null;){if(ie.key===Ie)if(ie.tag===4&&ie.stateNode.containerInfo===se.containerInfo&&ie.stateNode.implementation===se.implementation){l(ae,ie.sibling),pe=b(ie,se.children||[]),pe.return=ae,ae=pe;break e}else{l(ae,ie);break}else i(ae,ie);ie=ie.sibling}pe=Sd(se,ae.mode,pe),pe.return=ae,ae=pe}return A(ae);case B:return se=Aa(se),Nt(ae,ie,se,pe)}if($(se))return Te(ae,ie,se,pe);if(V(se)){if(Ie=V(se),typeof Ie!="function")throw Error(a(150));return se=Ie.call(se),He(ae,ie,se,pe)}if(typeof se.then=="function")return Nt(ae,ie,ac(se),pe);if(se.$$typeof===E)return Nt(ae,ie,tc(ae,se),pe);sc(ae,se)}return typeof se=="string"&&se!==""||typeof se=="number"||typeof se=="bigint"?(se=""+se,ie!==null&&ie.tag===6?(l(ae,ie.sibling),pe=b(ie,se),pe.return=ae,ae=pe):(l(ae,ie),pe=Nd(se,ae.mode,pe),pe.return=ae,ae=pe),A(ae)):l(ae,ie)}return function(ae,ie,se,pe){try{yl=0;var Ie=Nt(ae,ie,se,pe);return bs=null,Ie}catch(je){if(je===gs||je===rc)throw je;var ht=Zn(29,je,null,ae.mode);return ht.lanes=pe,ht.return=ae,ht}finally{}}}var Oa=sb(!0),lb=sb(!1),Ui=!1;function Id(n){n.updateQueue={baseState:n.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Bd(n,i){n=n.updateQueue,i.updateQueue===n&&(i.updateQueue={baseState:n.baseState,firstBaseUpdate:n.firstBaseUpdate,lastBaseUpdate:n.lastBaseUpdate,shared:n.shared,callbacks:null})}function Hi(n){return{lane:n,tag:0,payload:null,callback:null,next:null}}function $i(n,i,l){var u=n.updateQueue;if(u===null)return null;if(u=u.shared,(pt&2)!==0){var b=u.pending;return b===null?i.next=i:(i.next=b.next,b.next=i),u.pending=i,i=Qo(n),Fg(n,null,l),i}return Zo(n,u,i,l),Qo(n)}function _l(n,i,l){if(i=i.updateQueue,i!==null&&(i=i.shared,(l&4194048)!==0)){var u=i.lanes;u&=n.pendingLanes,l|=u,i.lanes=l,Ue(n,l)}}function Ud(n,i){var l=n.updateQueue,u=n.alternate;if(u!==null&&(u=u.updateQueue,l===u)){var b=null,v=null;if(l=l.firstBaseUpdate,l!==null){do{var A={lane:l.lane,tag:l.tag,payload:l.payload,callback:null,next:null};v===null?b=v=A:v=v.next=A,l=l.next}while(l!==null);v===null?b=v=i:v=v.next=i}else b=v=i;l={baseState:u.baseState,firstBaseUpdate:b,lastBaseUpdate:v,shared:u.shared,callbacks:u.callbacks},n.updateQueue=l;return}n=l.lastBaseUpdate,n===null?l.firstBaseUpdate=i:n.next=i,l.lastBaseUpdate=i}var Hd=!1;function wl(){if(Hd){var n=ps;if(n!==null)throw n}}function El(n,i,l,u){Hd=!1;var b=n.updateQueue;Ui=!1;var v=b.firstBaseUpdate,A=b.lastBaseUpdate,F=b.shared.pending;if(F!==null){b.shared.pending=null;var ne=F,le=ne.next;ne.next=null,A===null?v=le:A.next=le,A=ne;var he=n.alternate;he!==null&&(he=he.updateQueue,F=he.lastBaseUpdate,F!==A&&(F===null?he.firstBaseUpdate=le:F.next=le,he.lastBaseUpdate=ne))}if(v!==null){var ge=b.baseState;A=0,he=le=ne=null,F=v;do{var oe=F.lane&-536870913,de=oe!==F.lane;if(de?(it&oe)===oe:(u&oe)===oe){oe!==0&&oe===ms&&(Hd=!0),he!==null&&(he=he.next={lane:0,tag:F.tag,payload:F.payload,callback:null,next:null});e:{var Te=n,He=F;oe=i;var Nt=l;switch(He.tag){case 1:if(Te=He.payload,typeof Te=="function"){ge=Te.call(Nt,ge,oe);break e}ge=Te;break e;case 3:Te.flags=Te.flags&-65537|128;case 0:if(Te=He.payload,oe=typeof Te=="function"?Te.call(Nt,ge,oe):Te,oe==null)break e;ge=p({},ge,oe);break e;case 2:Ui=!0}}oe=F.callback,oe!==null&&(n.flags|=64,de&&(n.flags|=8192),de=b.callbacks,de===null?b.callbacks=[oe]:de.push(oe))}else de={lane:oe,tag:F.tag,payload:F.payload,callback:F.callback,next:null},he===null?(le=he=de,ne=ge):he=he.next=de,A|=oe;if(F=F.next,F===null){if(F=b.shared.pending,F===null)break;de=F,F=de.next,de.next=null,b.lastBaseUpdate=de,b.shared.pending=null}}while(!0);he===null&&(ne=ge),b.baseState=ne,b.firstBaseUpdate=le,b.lastBaseUpdate=he,v===null&&(b.shared.lanes=0),Vi|=A,n.lanes=A,n.memoizedState=ge}}function ob(n,i){if(typeof n!="function")throw Error(a(191,n));n.call(i)}function cb(n,i){var l=n.callbacks;if(l!==null)for(n.callbacks=null,n=0;nv?v:8;var A=O.T,F={};O.T=F,sf(n,!1,i,l);try{var ne=b(),le=O.S;if(le!==null&&le(F,ne),ne!==null&&typeof ne=="object"&&typeof ne.then=="function"){var he=l2(ne,u);kl(n,i,he,tr(n))}else kl(n,i,u,tr(n))}catch(ge){kl(n,i,{then:function(){},status:"rejected",reason:ge},tr())}finally{H.p=v,A!==null&&F.types!==null&&(A.types=F.types),O.T=A}}function h2(){}function rf(n,i,l,u){if(n.tag!==5)throw Error(a(476));var b=$b(n).queue;Hb(n,b,i,X,l===null?h2:function(){return qb(n),l(u)})}function $b(n){var i=n.memoizedState;if(i!==null)return i;i={memoizedState:X,baseState:X,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:li,lastRenderedState:X},next:null};var l={};return i.next={memoizedState:l,baseState:l,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:li,lastRenderedState:l},next:null},n.memoizedState=i,n=n.alternate,n!==null&&(n.memoizedState=i),i}function qb(n){var i=$b(n);i.next===null&&(i=n.alternate.memoizedState),kl(n,i.next.queue,{},tr())}function af(){return yn(Pl)}function Pb(){return Qt().memoizedState}function Fb(){return Qt().memoizedState}function m2(n){for(var i=n.return;i!==null;){switch(i.tag){case 24:case 3:var l=tr();n=Hi(l);var u=$i(i,n,l);u!==null&&(Pn(u,i,l),_l(u,i,l)),i={cache:jd()},n.payload=i;return}i=i.return}}function p2(n,i,l){var u=tr();l={lane:u,revertLane:0,gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null},gc(n)?Vb(i,l):(l=wd(n,i,l,u),l!==null&&(Pn(l,n,u),Yb(l,i,u)))}function Gb(n,i,l){var u=tr();kl(n,i,l,u)}function kl(n,i,l,u){var b={lane:u,revertLane:0,gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null};if(gc(n))Vb(i,b);else{var v=n.alternate;if(n.lanes===0&&(v===null||v.lanes===0)&&(v=i.lastRenderedReducer,v!==null))try{var A=i.lastRenderedState,F=v(A,l);if(b.hasEagerState=!0,b.eagerState=F,Kn(F,A))return Zo(n,i,b,0),kt===null&&Ko(),!1}catch{}finally{}if(l=wd(n,i,b,u),l!==null)return Pn(l,n,u),Yb(l,i,u),!0}return!1}function sf(n,i,l,u){if(u={lane:2,revertLane:Bf(),gesture:null,action:u,hasEagerState:!1,eagerState:null,next:null},gc(n)){if(i)throw Error(a(479))}else i=wd(n,l,u,2),i!==null&&Pn(i,n,2)}function gc(n){var i=n.alternate;return n===Xe||i!==null&&i===Xe}function Vb(n,i){ys=cc=!0;var l=n.pending;l===null?i.next=i:(i.next=l.next,l.next=i),n.pending=i}function Yb(n,i,l){if((l&4194048)!==0){var u=i.lanes;u&=n.pendingLanes,l|=u,i.lanes=l,Ue(n,l)}}var Cl={readContext:yn,use:fc,useCallback:Gt,useContext:Gt,useEffect:Gt,useImperativeHandle:Gt,useLayoutEffect:Gt,useInsertionEffect:Gt,useMemo:Gt,useReducer:Gt,useRef:Gt,useState:Gt,useDebugValue:Gt,useDeferredValue:Gt,useTransition:Gt,useSyncExternalStore:Gt,useId:Gt,useHostTransitionStatus:Gt,useFormState:Gt,useActionState:Gt,useOptimistic:Gt,useMemoCache:Gt,useCacheRefresh:Gt};Cl.useEffectEvent=Gt;var Xb={readContext:yn,use:fc,useCallback:function(n,i){return Dn().memoizedState=[n,i===void 0?null:i],n},useContext:yn,useEffect:Ob,useImperativeHandle:function(n,i,l){l=l!=null?l.concat([n]):null,mc(4194308,4,Lb.bind(null,i,n),l)},useLayoutEffect:function(n,i){return mc(4194308,4,n,i)},useInsertionEffect:function(n,i){mc(4,2,n,i)},useMemo:function(n,i){var l=Dn();i=i===void 0?null:i;var u=n();if(Ra){Wt(!0);try{n()}finally{Wt(!1)}}return l.memoizedState=[u,i],u},useReducer:function(n,i,l){var u=Dn();if(l!==void 0){var b=l(i);if(Ra){Wt(!0);try{l(i)}finally{Wt(!1)}}}else b=i;return u.memoizedState=u.baseState=b,n={pending:null,lanes:0,dispatch:null,lastRenderedReducer:n,lastRenderedState:b},u.queue=n,n=n.dispatch=p2.bind(null,Xe,n),[u.memoizedState,n]},useRef:function(n){var i=Dn();return n={current:n},i.memoizedState=n},useState:function(n){n=Wd(n);var i=n.queue,l=Gb.bind(null,Xe,i);return i.dispatch=l,[n.memoizedState,l]},useDebugValue:tf,useDeferredValue:function(n,i){var l=Dn();return nf(l,n,i)},useTransition:function(){var n=Wd(!1);return n=Hb.bind(null,Xe,n.queue,!0,!1),Dn().memoizedState=n,[!1,n]},useSyncExternalStore:function(n,i,l){var u=Xe,b=Dn();if(lt){if(l===void 0)throw Error(a(407));l=l()}else{if(l=i(),kt===null)throw Error(a(349));(it&127)!==0||pb(u,i,l)}b.memoizedState=l;var v={value:l,getSnapshot:i};return b.queue=v,Ob(bb.bind(null,u,v,n),[n]),u.flags|=2048,_s(9,{destroy:void 0},gb.bind(null,u,v,l,i),null),l},useId:function(){var n=Dn(),i=kt.identifierPrefix;if(lt){var l=$r,u=Hr;l=(u&~(1<<32-ut(u)-1)).toString(32)+l,i="_"+i+"R_"+l,l=uc++,0<\/script>",v=v.removeChild(v.firstChild);break;case"select":v=typeof u.is=="string"?A.createElement("select",{is:u.is}):A.createElement("select"),u.multiple?v.multiple=!0:u.size&&(v.size=u.size);break;default:v=typeof u.is=="string"?A.createElement(b,{is:u.is}):A.createElement(b)}}v[Ut]=i,v[mn]=u;e:for(A=i.child;A!==null;){if(A.tag===5||A.tag===6)v.appendChild(A.stateNode);else if(A.tag!==4&&A.tag!==27&&A.child!==null){A.child.return=A,A=A.child;continue}if(A===i)break e;for(;A.sibling===null;){if(A.return===null||A.return===i)break e;A=A.return}A.sibling.return=A.return,A=A.sibling}i.stateNode=v;e:switch(_n(v,b,u),b){case"button":case"input":case"select":case"textarea":u=!!u.autoFocus;break e;case"img":u=!0;break e;default:u=!1}u&&ci(i)}}return Dt(i),vf(i,i.type,n===null?null:n.memoizedProps,i.pendingProps,l),null;case 6:if(n&&i.stateNode!=null)n.memoizedProps!==u&&ci(i);else{if(typeof u!="string"&&i.stateNode===null)throw Error(a(166));if(n=Q.current,fs(i)){if(n=i.stateNode,l=i.memoizedProps,u=null,b=xn,b!==null)switch(b.tag){case 27:case 5:u=b.memoizedProps}n[Ut]=i,n=!!(n.nodeValue===l||u!==null&&u.suppressHydrationWarning===!0||h0(n.nodeValue,l)),n||Ii(i,!0)}else n=zc(n).createTextNode(u),n[Ut]=i,i.stateNode=n}return Dt(i),null;case 31:if(l=i.memoizedState,n===null||n.memoizedState!==null){if(u=fs(i),l!==null){if(n===null){if(!u)throw Error(a(318));if(n=i.memoizedState,n=n!==null?n.dehydrated:null,!n)throw Error(a(557));n[Ut]=i}else Sa(),(i.flags&128)===0&&(i.memoizedState=null),i.flags|=4;Dt(i),n=!1}else l=Ad(),n!==null&&n.memoizedState!==null&&(n.memoizedState.hydrationErrors=l),n=!0;if(!n)return i.flags&256?(Wn(i),i):(Wn(i),null);if((i.flags&128)!==0)throw Error(a(558))}return Dt(i),null;case 13:if(u=i.memoizedState,n===null||n.memoizedState!==null&&n.memoizedState.dehydrated!==null){if(b=fs(i),u!==null&&u.dehydrated!==null){if(n===null){if(!b)throw Error(a(318));if(b=i.memoizedState,b=b!==null?b.dehydrated:null,!b)throw Error(a(317));b[Ut]=i}else Sa(),(i.flags&128)===0&&(i.memoizedState=null),i.flags|=4;Dt(i),b=!1}else b=Ad(),n!==null&&n.memoizedState!==null&&(n.memoizedState.hydrationErrors=b),b=!0;if(!b)return i.flags&256?(Wn(i),i):(Wn(i),null)}return Wn(i),(i.flags&128)!==0?(i.lanes=l,i):(l=u!==null,n=n!==null&&n.memoizedState!==null,l&&(u=i.child,b=null,u.alternate!==null&&u.alternate.memoizedState!==null&&u.alternate.memoizedState.cachePool!==null&&(b=u.alternate.memoizedState.cachePool.pool),v=null,u.memoizedState!==null&&u.memoizedState.cachePool!==null&&(v=u.memoizedState.cachePool.pool),v!==b&&(u.flags|=2048)),l!==n&&l&&(i.child.flags|=8192),_c(i,i.updateQueue),Dt(i),null);case 4:return te(),n===null&&qf(i.stateNode.containerInfo),Dt(i),null;case 10:return ai(i.type),Dt(i),null;case 19:if(Y(Zt),u=i.memoizedState,u===null)return Dt(i),null;if(b=(i.flags&128)!==0,v=u.rendering,v===null)if(b)Al(u,!1);else{if(Vt!==0||n!==null&&(n.flags&128)!==0)for(n=i.child;n!==null;){if(v=oc(n),v!==null){for(i.flags|=128,Al(u,!1),n=v.updateQueue,i.updateQueue=n,_c(i,n),i.subtreeFlags=0,n=l,l=i.child;l!==null;)Gg(l,n),l=l.sibling;return L(Zt,Zt.current&1|2),lt&&ri(i,u.treeForkCount),i.child}n=n.sibling}u.tail!==null&&ct()>kc&&(i.flags|=128,b=!0,Al(u,!1),i.lanes=4194304)}else{if(!b)if(n=oc(v),n!==null){if(i.flags|=128,b=!0,n=n.updateQueue,i.updateQueue=n,_c(i,n),Al(u,!0),u.tail===null&&u.tailMode==="hidden"&&!v.alternate&&!lt)return Dt(i),null}else 2*ct()-u.renderingStartTime>kc&&l!==536870912&&(i.flags|=128,b=!0,Al(u,!1),i.lanes=4194304);u.isBackwards?(v.sibling=i.child,i.child=v):(n=u.last,n!==null?n.sibling=v:i.child=v,u.last=v)}return u.tail!==null?(n=u.tail,u.rendering=n,u.tail=n.sibling,u.renderingStartTime=ct(),n.sibling=null,l=Zt.current,L(Zt,b?l&1|2:l&1),lt&&ri(i,u.treeForkCount),n):(Dt(i),null);case 22:case 23:return Wn(i),qd(),u=i.memoizedState!==null,n!==null?n.memoizedState!==null!==u&&(i.flags|=8192):u&&(i.flags|=8192),u?(l&536870912)!==0&&(i.flags&128)===0&&(Dt(i),i.subtreeFlags&6&&(i.flags|=8192)):Dt(i),l=i.updateQueue,l!==null&&_c(i,l.retryQueue),l=null,n!==null&&n.memoizedState!==null&&n.memoizedState.cachePool!==null&&(l=n.memoizedState.cachePool.pool),u=null,i.memoizedState!==null&&i.memoizedState.cachePool!==null&&(u=i.memoizedState.cachePool.pool),u!==l&&(i.flags|=2048),n!==null&&Y(Ta),null;case 24:return l=null,n!==null&&(l=n.memoizedState.cache),i.memoizedState.cache!==l&&(i.flags|=2048),ai(en),Dt(i),null;case 25:return null;case 30:return null}throw Error(a(156,i.tag))}function v2(n,i){switch(Cd(i),i.tag){case 1:return n=i.flags,n&65536?(i.flags=n&-65537|128,i):null;case 3:return ai(en),te(),n=i.flags,(n&65536)!==0&&(n&128)===0?(i.flags=n&-65537|128,i):null;case 26:case 27:case 5:return fe(i),null;case 31:if(i.memoizedState!==null){if(Wn(i),i.alternate===null)throw Error(a(340));Sa()}return n=i.flags,n&65536?(i.flags=n&-65537|128,i):null;case 13:if(Wn(i),n=i.memoizedState,n!==null&&n.dehydrated!==null){if(i.alternate===null)throw Error(a(340));Sa()}return n=i.flags,n&65536?(i.flags=n&-65537|128,i):null;case 19:return Y(Zt),null;case 4:return te(),null;case 10:return ai(i.type),null;case 22:case 23:return Wn(i),qd(),n!==null&&Y(Ta),n=i.flags,n&65536?(i.flags=n&-65537|128,i):null;case 24:return ai(en),null;case 25:return null;default:return null}}function xx(n,i){switch(Cd(i),i.tag){case 3:ai(en),te();break;case 26:case 27:case 5:fe(i);break;case 4:te();break;case 31:i.memoizedState!==null&&Wn(i);break;case 13:Wn(i);break;case 19:Y(Zt);break;case 10:ai(i.type);break;case 22:case 23:Wn(i),qd(),n!==null&&Y(Ta);break;case 24:ai(en)}}function Ml(n,i){try{var l=i.updateQueue,u=l!==null?l.lastEffect:null;if(u!==null){var b=u.next;l=b;do{if((l.tag&n)===n){u=void 0;var v=l.create,A=l.inst;u=v(),A.destroy=u}l=l.next}while(l!==b)}}catch(F){yt(i,i.return,F)}}function Fi(n,i,l){try{var u=i.updateQueue,b=u!==null?u.lastEffect:null;if(b!==null){var v=b.next;u=v;do{if((u.tag&n)===n){var A=u.inst,F=A.destroy;if(F!==void 0){A.destroy=void 0,b=i;var ne=l,le=F;try{le()}catch(he){yt(b,ne,he)}}}u=u.next}while(u!==v)}}catch(he){yt(i,i.return,he)}}function yx(n){var i=n.updateQueue;if(i!==null){var l=n.stateNode;try{cb(i,l)}catch(u){yt(n,n.return,u)}}}function vx(n,i,l){l.props=ja(n.type,n.memoizedProps),l.state=n.memoizedState;try{l.componentWillUnmount()}catch(u){yt(n,i,u)}}function Ol(n,i){try{var l=n.ref;if(l!==null){switch(n.tag){case 26:case 27:case 5:var u=n.stateNode;break;case 30:u=n.stateNode;break;default:u=n.stateNode}typeof l=="function"?n.refCleanup=l(u):l.current=u}}catch(b){yt(n,i,b)}}function qr(n,i){var l=n.ref,u=n.refCleanup;if(l!==null)if(typeof u=="function")try{u()}catch(b){yt(n,i,b)}finally{n.refCleanup=null,n=n.alternate,n!=null&&(n.refCleanup=null)}else if(typeof l=="function")try{l(null)}catch(b){yt(n,i,b)}else l.current=null}function _x(n){var i=n.type,l=n.memoizedProps,u=n.stateNode;try{e:switch(i){case"button":case"input":case"select":case"textarea":l.autoFocus&&u.focus();break e;case"img":l.src?u.src=l.src:l.srcSet&&(u.srcset=l.srcSet)}}catch(b){yt(n,n.return,b)}}function _f(n,i,l){try{var u=n.stateNode;q2(u,n.type,l,i),u[mn]=i}catch(b){yt(n,n.return,b)}}function wx(n){return n.tag===5||n.tag===3||n.tag===26||n.tag===27&&Qi(n.type)||n.tag===4}function wf(n){e:for(;;){for(;n.sibling===null;){if(n.return===null||wx(n.return))return null;n=n.return}for(n.sibling.return=n.return,n=n.sibling;n.tag!==5&&n.tag!==6&&n.tag!==18;){if(n.tag===27&&Qi(n.type)||n.flags&2||n.child===null||n.tag===4)continue e;n.child.return=n,n=n.child}if(!(n.flags&2))return n.stateNode}}function Ef(n,i,l){var u=n.tag;if(u===5||u===6)n=n.stateNode,i?(l.nodeType===9?l.body:l.nodeName==="HTML"?l.ownerDocument.body:l).insertBefore(n,i):(i=l.nodeType===9?l.body:l.nodeName==="HTML"?l.ownerDocument.body:l,i.appendChild(n),l=l._reactRootContainer,l!=null||i.onclick!==null||(i.onclick=_e));else if(u!==4&&(u===27&&Qi(n.type)&&(l=n.stateNode,i=null),n=n.child,n!==null))for(Ef(n,i,l),n=n.sibling;n!==null;)Ef(n,i,l),n=n.sibling}function wc(n,i,l){var u=n.tag;if(u===5||u===6)n=n.stateNode,i?l.insertBefore(n,i):l.appendChild(n);else if(u!==4&&(u===27&&Qi(n.type)&&(l=n.stateNode),n=n.child,n!==null))for(wc(n,i,l),n=n.sibling;n!==null;)wc(n,i,l),n=n.sibling}function Ex(n){var i=n.stateNode,l=n.memoizedProps;try{for(var u=n.type,b=i.attributes;b.length;)i.removeAttributeNode(b[0]);_n(i,u,l),i[Ut]=n,i[mn]=l}catch(v){yt(n,n.return,v)}}var ui=!1,rn=!1,Nf=!1,Nx=typeof WeakSet=="function"?WeakSet:Set,gn=null;function _2(n,i){if(n=n.containerInfo,Gf=Pc,n=zg(n),gd(n)){if("selectionStart"in n)var l={start:n.selectionStart,end:n.selectionEnd};else e:{l=(l=n.ownerDocument)&&l.defaultView||window;var u=l.getSelection&&l.getSelection();if(u&&u.rangeCount!==0){l=u.anchorNode;var b=u.anchorOffset,v=u.focusNode;u=u.focusOffset;try{l.nodeType,v.nodeType}catch{l=null;break e}var A=0,F=-1,ne=-1,le=0,he=0,ge=n,oe=null;t:for(;;){for(var de;ge!==l||b!==0&&ge.nodeType!==3||(F=A+b),ge!==v||u!==0&&ge.nodeType!==3||(ne=A+u),ge.nodeType===3&&(A+=ge.nodeValue.length),(de=ge.firstChild)!==null;)oe=ge,ge=de;for(;;){if(ge===n)break t;if(oe===l&&++le===b&&(F=A),oe===v&&++he===u&&(ne=A),(de=ge.nextSibling)!==null)break;ge=oe,oe=ge.parentNode}ge=de}l=F===-1||ne===-1?null:{start:F,end:ne}}else l=null}l=l||{start:0,end:0}}else l=null;for(Vf={focusedElem:n,selectionRange:l},Pc=!1,gn=i;gn!==null;)if(i=gn,n=i.child,(i.subtreeFlags&1028)!==0&&n!==null)n.return=i,gn=n;else for(;gn!==null;){switch(i=gn,v=i.alternate,n=i.flags,i.tag){case 0:if((n&4)!==0&&(n=i.updateQueue,n=n!==null?n.events:null,n!==null))for(l=0;l title"))),_n(v,u,l),v[Ut]=n,Ft(v),u=v;break e;case"link":var A=M0("link","href",b).get(u+(l.href||""));if(A){for(var F=0;FNt&&(A=Nt,Nt=He,He=A);var ae=Dg(F,He),ie=Dg(F,Nt);if(ae&&ie&&(de.rangeCount!==1||de.anchorNode!==ae.node||de.anchorOffset!==ae.offset||de.focusNode!==ie.node||de.focusOffset!==ie.offset)){var se=ge.createRange();se.setStart(ae.node,ae.offset),de.removeAllRanges(),He>Nt?(de.addRange(se),de.extend(ie.node,ie.offset)):(se.setEnd(ie.node,ie.offset),de.addRange(se))}}}}for(ge=[],de=F;de=de.parentNode;)de.nodeType===1&&ge.push({element:de,left:de.scrollLeft,top:de.scrollTop});for(typeof F.focus=="function"&&F.focus(),F=0;Fl?32:l,O.T=null,l=Of,Of=null;var v=Xi,A=pi;if(dn=0,ks=Xi=null,pi=0,(pt&6)!==0)throw Error(a(331));var F=pt;if(pt|=4,Lx(v.current),Rx(v,v.current,A,l),pt=F,Il(0,!1),At&&typeof At.onPostCommitFiberRoot=="function")try{At.onPostCommitFiberRoot(Kt,v)}catch{}return!0}finally{H.p=b,O.T=u,Jx(n,i)}}function t0(n,i,l){i=ur(l,i),i=uf(n.stateNode,i,2),n=$i(n,i,2),n!==null&&(gt(n,2),Pr(n))}function yt(n,i,l){if(n.tag===3)t0(n,n,l);else for(;i!==null;){if(i.tag===3){t0(i,n,l);break}else if(i.tag===1){var u=i.stateNode;if(typeof i.type.getDerivedStateFromError=="function"||typeof u.componentDidCatch=="function"&&(Yi===null||!Yi.has(u))){n=ur(l,n),l=nx(2),u=$i(i,l,2),u!==null&&(rx(l,u,i,n),gt(u,2),Pr(u));break}}i=i.return}}function Lf(n,i,l){var u=n.pingCache;if(u===null){u=n.pingCache=new N2;var b=new Set;u.set(i,b)}else b=u.get(i),b===void 0&&(b=new Set,u.set(i,b));b.has(l)||(Cf=!0,b.add(l),n=A2.bind(null,n,i,l),i.then(n,n))}function A2(n,i,l){var u=n.pingCache;u!==null&&u.delete(i),n.pingedLanes|=n.suspendedLanes&l,n.warmLanes&=~l,kt===n&&(it&l)===l&&(Vt===4||Vt===3&&(it&62914560)===it&&300>ct()-Sc?(pt&2)===0&&Cs(n,0):Tf|=l,Ss===it&&(Ss=0)),Pr(n)}function n0(n,i){i===0&&(i=Pe()),n=Ea(n,i),n!==null&&(gt(n,i),Pr(n))}function M2(n){var i=n.memoizedState,l=0;i!==null&&(l=i.retryLane),n0(n,l)}function O2(n,i){var l=0;switch(n.tag){case 31:case 13:var u=n.stateNode,b=n.memoizedState;b!==null&&(l=b.retryLane);break;case 19:u=n.stateNode;break;case 22:u=n.stateNode._retryCache;break;default:throw Error(a(314))}u!==null&&u.delete(i),n0(n,l)}function R2(n,i){return Pt(n,i)}var Rc=null,As=null,zf=!1,jc=!1,If=!1,Zi=0;function Pr(n){n!==As&&n.next===null&&(As===null?Rc=As=n:As=As.next=n),jc=!0,zf||(zf=!0,D2())}function Il(n,i){if(!If&&jc){If=!0;do for(var l=!1,u=Rc;u!==null;){if(n!==0){var b=u.pendingLanes;if(b===0)var v=0;else{var A=u.suspendedLanes,F=u.pingedLanes;v=(1<<31-ut(42|n)+1)-1,v&=b&~(A&~F),v=v&201326741?v&201326741|1:v?v|2:0}v!==0&&(l=!0,s0(u,v))}else v=it,v=re(u,u===kt?v:0,u.cancelPendingCommit!==null||u.timeoutHandle!==-1),(v&3)===0||me(u,v)||(l=!0,s0(u,v));u=u.next}while(l);If=!1}}function j2(){r0()}function r0(){jc=zf=!1;var n=0;Zi!==0&&F2()&&(n=Zi);for(var i=ct(),l=null,u=Rc;u!==null;){var b=u.next,v=i0(u,i);v===0?(u.next=null,l===null?Rc=b:l.next=b,b===null&&(As=l)):(l=u,(n!==0||(v&3)!==0)&&(jc=!0)),u=b}dn!==0&&dn!==5||Il(n),Zi!==0&&(Zi=0)}function i0(n,i){for(var l=n.suspendedLanes,u=n.pingedLanes,b=n.expirationTimes,v=n.pendingLanes&-62914561;0F)break;var he=ne.transferSize,ge=ne.initiatorType;he&&m0(ge)&&(ne=ne.responseEnd,A+=he*(ne"u"?null:document;function k0(n,i,l){var u=Ms;if(u&&typeof i=="string"&&i){var b=kn(i);b='link[rel="'+n+'"][href="'+b+'"]',typeof l=="string"&&(b+='[crossorigin="'+l+'"]'),S0.has(b)||(S0.add(b),n={rel:n,crossOrigin:l,href:i},u.querySelector(b)===null&&(i=u.createElement("link"),_n(i,"link",n),Ft(i),u.head.appendChild(i)))}}function J2(n){gi.D(n),k0("dns-prefetch",n,null)}function ek(n,i){gi.C(n,i),k0("preconnect",n,i)}function tk(n,i,l){gi.L(n,i,l);var u=Ms;if(u&&n&&i){var b='link[rel="preload"][as="'+kn(i)+'"]';i==="image"&&l&&l.imageSrcSet?(b+='[imagesrcset="'+kn(l.imageSrcSet)+'"]',typeof l.imageSizes=="string"&&(b+='[imagesizes="'+kn(l.imageSizes)+'"]')):b+='[href="'+kn(n)+'"]';var v=b;switch(i){case"style":v=Os(n);break;case"script":v=Rs(n)}gr.has(v)||(n=p({rel:"preload",href:i==="image"&&l&&l.imageSrcSet?void 0:n,as:i},l),gr.set(v,n),u.querySelector(b)!==null||i==="style"&&u.querySelector($l(v))||i==="script"&&u.querySelector(ql(v))||(i=u.createElement("link"),_n(i,"link",n),Ft(i),u.head.appendChild(i)))}}function nk(n,i){gi.m(n,i);var l=Ms;if(l&&n){var u=i&&typeof i.as=="string"?i.as:"script",b='link[rel="modulepreload"][as="'+kn(u)+'"][href="'+kn(n)+'"]',v=b;switch(u){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":v=Rs(n)}if(!gr.has(v)&&(n=p({rel:"modulepreload",href:n},i),gr.set(v,n),l.querySelector(b)===null)){switch(u){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(l.querySelector(ql(v)))return}u=l.createElement("link"),_n(u,"link",n),Ft(u),l.head.appendChild(u)}}}function rk(n,i,l){gi.S(n,i,l);var u=Ms;if(u&&n){var b=Br(u).hoistableStyles,v=Os(n);i=i||"default";var A=b.get(v);if(!A){var F={loading:0,preload:null};if(A=u.querySelector($l(v)))F.loading=5;else{n=p({rel:"stylesheet",href:n,"data-precedence":i},l),(l=gr.get(v))&&Jf(n,l);var ne=A=u.createElement("link");Ft(ne),_n(ne,"link",n),ne._p=new Promise(function(le,he){ne.onload=le,ne.onerror=he}),ne.addEventListener("load",function(){F.loading|=1}),ne.addEventListener("error",function(){F.loading|=2}),F.loading|=4,Bc(A,i,u)}A={type:"stylesheet",instance:A,count:1,state:F},b.set(v,A)}}}function ik(n,i){gi.X(n,i);var l=Ms;if(l&&n){var u=Br(l).hoistableScripts,b=Rs(n),v=u.get(b);v||(v=l.querySelector(ql(b)),v||(n=p({src:n,async:!0},i),(i=gr.get(b))&&eh(n,i),v=l.createElement("script"),Ft(v),_n(v,"link",n),l.head.appendChild(v)),v={type:"script",instance:v,count:1,state:null},u.set(b,v))}}function ak(n,i){gi.M(n,i);var l=Ms;if(l&&n){var u=Br(l).hoistableScripts,b=Rs(n),v=u.get(b);v||(v=l.querySelector(ql(b)),v||(n=p({src:n,async:!0,type:"module"},i),(i=gr.get(b))&&eh(n,i),v=l.createElement("script"),Ft(v),_n(v,"link",n),l.head.appendChild(v)),v={type:"script",instance:v,count:1,state:null},u.set(b,v))}}function C0(n,i,l,u){var b=(b=Q.current)?Ic(b):null;if(!b)throw Error(a(446));switch(n){case"meta":case"title":return null;case"style":return typeof l.precedence=="string"&&typeof l.href=="string"?(i=Os(l.href),l=Br(b).hoistableStyles,u=l.get(i),u||(u={type:"style",instance:null,count:0,state:null},l.set(i,u)),u):{type:"void",instance:null,count:0,state:null};case"link":if(l.rel==="stylesheet"&&typeof l.href=="string"&&typeof l.precedence=="string"){n=Os(l.href);var v=Br(b).hoistableStyles,A=v.get(n);if(A||(b=b.ownerDocument||b,A={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},v.set(n,A),(v=b.querySelector($l(n)))&&!v._p&&(A.instance=v,A.state.loading=5),gr.has(n)||(l={rel:"preload",as:"style",href:l.href,crossOrigin:l.crossOrigin,integrity:l.integrity,media:l.media,hrefLang:l.hrefLang,referrerPolicy:l.referrerPolicy},gr.set(n,l),v||sk(b,n,l,A.state))),i&&u===null)throw Error(a(528,""));return A}if(i&&u!==null)throw Error(a(529,""));return null;case"script":return i=l.async,l=l.src,typeof l=="string"&&i&&typeof i!="function"&&typeof i!="symbol"?(i=Rs(l),l=Br(b).hoistableScripts,u=l.get(i),u||(u={type:"script",instance:null,count:0,state:null},l.set(i,u)),u):{type:"void",instance:null,count:0,state:null};default:throw Error(a(444,n))}}function Os(n){return'href="'+kn(n)+'"'}function $l(n){return'link[rel="stylesheet"]['+n+"]"}function T0(n){return p({},n,{"data-precedence":n.precedence,precedence:null})}function sk(n,i,l,u){n.querySelector('link[rel="preload"][as="style"]['+i+"]")?u.loading=1:(i=n.createElement("link"),u.preload=i,i.addEventListener("load",function(){return u.loading|=1}),i.addEventListener("error",function(){return u.loading|=2}),_n(i,"link",l),Ft(i),n.head.appendChild(i))}function Rs(n){return'[src="'+kn(n)+'"]'}function ql(n){return"script[async]"+n}function A0(n,i,l){if(i.count++,i.instance===null)switch(i.type){case"style":var u=n.querySelector('style[data-href~="'+kn(l.href)+'"]');if(u)return i.instance=u,Ft(u),u;var b=p({},l,{"data-href":l.href,"data-precedence":l.precedence,href:null,precedence:null});return u=(n.ownerDocument||n).createElement("style"),Ft(u),_n(u,"style",b),Bc(u,l.precedence,n),i.instance=u;case"stylesheet":b=Os(l.href);var v=n.querySelector($l(b));if(v)return i.state.loading|=4,i.instance=v,Ft(v),v;u=T0(l),(b=gr.get(b))&&Jf(u,b),v=(n.ownerDocument||n).createElement("link"),Ft(v);var A=v;return A._p=new Promise(function(F,ne){A.onload=F,A.onerror=ne}),_n(v,"link",u),i.state.loading|=4,Bc(v,l.precedence,n),i.instance=v;case"script":return v=Rs(l.src),(b=n.querySelector(ql(v)))?(i.instance=b,Ft(b),b):(u=l,(b=gr.get(v))&&(u=p({},l),eh(u,b)),n=n.ownerDocument||n,b=n.createElement("script"),Ft(b),_n(b,"link",u),n.head.appendChild(b),i.instance=b);case"void":return null;default:throw Error(a(443,i.type))}else i.type==="stylesheet"&&(i.state.loading&4)===0&&(u=i.instance,i.state.loading|=4,Bc(u,l.precedence,n));return i.instance}function Bc(n,i,l){for(var u=l.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),b=u.length?u[u.length-1]:null,v=b,A=0;A title"):null)}function lk(n,i,l){if(l===1||i.itemProp!=null)return!1;switch(n){case"meta":case"title":return!0;case"style":if(typeof i.precedence!="string"||typeof i.href!="string"||i.href==="")break;return!0;case"link":if(typeof i.rel!="string"||typeof i.href!="string"||i.href===""||i.onLoad||i.onError)break;switch(i.rel){case"stylesheet":return n=i.disabled,typeof i.precedence=="string"&&n==null;default:return!0}case"script":if(i.async&&typeof i.async!="function"&&typeof i.async!="symbol"&&!i.onLoad&&!i.onError&&i.src&&typeof i.src=="string")return!0}return!1}function R0(n){return!(n.type==="stylesheet"&&(n.state.loading&3)===0)}function ok(n,i,l,u){if(l.type==="stylesheet"&&(typeof u.media!="string"||matchMedia(u.media).matches!==!1)&&(l.state.loading&4)===0){if(l.instance===null){var b=Os(u.href),v=i.querySelector($l(b));if(v){i=v._p,i!==null&&typeof i=="object"&&typeof i.then=="function"&&(n.count++,n=Hc.bind(n),i.then(n,n)),l.state.loading|=4,l.instance=v,Ft(v);return}v=i.ownerDocument||i,u=T0(u),(b=gr.get(b))&&Jf(u,b),v=v.createElement("link"),Ft(v);var A=v;A._p=new Promise(function(F,ne){A.onload=F,A.onerror=ne}),_n(v,"link",u),l.instance=v}n.stylesheets===null&&(n.stylesheets=new Map),n.stylesheets.set(l,i),(i=l.state.preload)&&(l.state.loading&3)===0&&(n.count++,l=Hc.bind(n),i.addEventListener("load",l),i.addEventListener("error",l))}}var th=0;function ck(n,i){return n.stylesheets&&n.count===0&&qc(n,n.stylesheets),0th?50:800)+i);return n.unsuspend=l,function(){n.unsuspend=null,clearTimeout(u),clearTimeout(b)}}:null}function Hc(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)qc(this,this.stylesheets);else if(this.unsuspend){var n=this.unsuspend;this.unsuspend=null,n()}}}var $c=null;function qc(n,i){n.stylesheets=null,n.unsuspend!==null&&(n.count++,$c=new Map,i.forEach(uk,n),$c=null,Hc.call(n))}function uk(n,i){if(!(i.state.loading&4)){var l=$c.get(n);if(l)var u=l.get(null);else{l=new Map,$c.set(n,l);for(var b=n.querySelectorAll("link[data-precedence],style[data-precedence]"),v=0;v"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(t){console.error(t)}}return e(),uh.exports=Sk(),uh.exports}var Ck=kk();/** * @license lucide-react v0.563.0 - ISC * * This source code is licensed under the ISC license. @@ -66,7 +66,7 @@ Error generating stack: `+u.message+` * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const ty=e=>{const t=Ak(e);return t.charAt(0).toUpperCase()+t.slice(1)};/** + */const ny=e=>{const t=Ak(e);return t.charAt(0).toUpperCase()+t.slice(1)};/** * @license lucide-react v0.563.0 - ISC * * This source code is licensed under the ISC license. @@ -86,12 +86,12 @@ Error generating stack: `+u.message+` * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const Me=(e,t)=>{const r=ee.forwardRef(({className:a,...s},o)=>ee.createElement(Rk,{ref:o,iconNode:t,className:C_(`lucide-${Tk(ty(e))}`,`lucide-${e}`,a),...s}));return r.displayName=ty(e),r};/** + */const Me=(e,t)=>{const r=ee.forwardRef(({className:a,...s},o)=>ee.createElement(Rk,{ref:o,iconNode:t,className:C_(`lucide-${Tk(ny(e))}`,`lucide-${e}`,a),...s}));return r.displayName=ny(e),r};/** * @license lucide-react v0.563.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const jk=[["path",{d:"m12 19-7-7 7-7",key:"1l729n"}],["path",{d:"M19 12H5",key:"x3x0zl"}]],bp=Me("arrow-left",jk);/** + */const jk=[["path",{d:"m12 19-7-7 7-7",key:"1l729n"}],["path",{d:"M19 12H5",key:"x3x0zl"}]],xp=Me("arrow-left",jk);/** * @license lucide-react v0.563.0 - ISC * * This source code is licensed under the ISC license. @@ -206,7 +206,7 @@ Error generating stack: `+u.message+` * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const fC=[["path",{d:"M15 3h6v6",key:"1q9fwt"}],["path",{d:"M10 14 21 3",key:"gplh6r"}],["path",{d:"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6",key:"a6xqqp"}]],ny=Me("external-link",fC);/** + */const fC=[["path",{d:"M15 3h6v6",key:"1q9fwt"}],["path",{d:"M10 14 21 3",key:"gplh6r"}],["path",{d:"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6",key:"a6xqqp"}]],ry=Me("external-link",fC);/** * @license lucide-react v0.563.0 - ISC * * This source code is licensed under the ISC license. @@ -286,12 +286,12 @@ Error generating stack: `+u.message+` * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const UC=[["path",{d:"m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7",key:"132q7q"}],["rect",{x:"2",y:"4",width:"20",height:"16",rx:"2",key:"izxlao"}]],xp=Me("mail",UC);/** + */const UC=[["path",{d:"m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7",key:"132q7q"}],["rect",{x:"2",y:"4",width:"20",height:"16",rx:"2",key:"izxlao"}]],yp=Me("mail",UC);/** * @license lucide-react v0.563.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const HC=[["path",{d:"M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719",key:"1sd12s"}]],ry=Me("message-circle",HC);/** + */const HC=[["path",{d:"M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719",key:"1sd12s"}]],mh=Me("message-circle",HC);/** * @license lucide-react v0.563.0 - ISC * * This source code is licensed under the ISC license. @@ -351,7 +351,7 @@ Error generating stack: `+u.message+` * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const oT=[["path",{d:"M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z",key:"1s2grr"}],["path",{d:"M20 2v4",key:"1rf3ol"}],["path",{d:"M22 4h-4",key:"gwowj6"}],["circle",{cx:"4",cy:"20",r:"2",key:"6kqj1y"}]],Bm=Me("sparkles",oT);/** + */const oT=[["path",{d:"M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z",key:"1s2grr"}],["path",{d:"M20 2v4",key:"1rf3ol"}],["path",{d:"M22 4h-4",key:"gwowj6"}],["circle",{cx:"4",cy:"20",r:"2",key:"6kqj1y"}]],Um=Me("sparkles",oT);/** * @license lucide-react v0.563.0 - ISC * * This source code is licensed under the ISC license. @@ -386,59 +386,59 @@ Error generating stack: `+u.message+` * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const vT=[["path",{d:"M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z",key:"1ngwbx"}]],Um=Me("wrench",vT);/** + */const vT=[["path",{d:"M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z",key:"1ngwbx"}]],Hm=Me("wrench",vT);/** * @license lucide-react v0.563.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const _T=[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]],yp=Me("x",_T);/** + */const _T=[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]],vp=Me("x",_T);/** * @license lucide-react v0.563.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const wT=[["path",{d:"M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z",key:"1xq2db"}]],ET=Me("zap",wT),NT={open:{label:"Open",color:"bg-red-500/10 text-red-400 border-red-500/20",dotColor:"bg-red-500",description:"Newly discovered, awaiting triage"},in_progress:{label:"In Progress",color:"bg-blue-500/10 text-blue-400 border-blue-500/20",dotColor:"bg-blue-500",description:"Someone is working on this"},snoozed:{label:"Snoozed",color:"bg-purple-500/10 text-purple-400 border-purple-500/20",dotColor:"bg-purple-500",description:"Temporarily hidden until a follow-up date"},fixed:{label:"Fixed",color:"bg-emerald-500/10 text-emerald-400 border-emerald-500/20",dotColor:"bg-emerald-500",description:"This vulnerability has been fixed"},ignored:{label:"Ignored",color:"bg-gray-500/10 text-gray-400 border-gray-500/20",dotColor:"bg-gray-500",description:"Acknowledged but accepted"}},ST={trivial:{label:"Trivial",color:"bg-emerald-500/10 text-emerald-400 border-emerald-500/20"},low:{label:"Low",color:"bg-blue-500/10 text-blue-400 border-blue-500/20"},medium:{label:"Medium",color:"bg-yellow-500/10 text-yellow-400 border-yellow-500/20"},high:{label:"High",color:"bg-orange-500/10 text-orange-400 border-orange-500/20"}},U_={critical:"bg-red-500/20 text-red-500 border-red-500/30",high:"bg-orange-500/20 text-orange-500 border-orange-500/30",medium:"bg-yellow-500/20 text-yellow-500 border-yellow-500/30",low:"bg-blue-500/20 text-blue-500 border-blue-500/30"};function Zc(e){return e.original_severity!=null&&e.original_severity!==e.severity}const kT={js:"javascript",ts:"typescript",tsx:"typescript",jsx:"javascript",py:"python",rb:"ruby",go:"go",rs:"rust",java:"java",php:"php",cs:"csharp",cpp:"cpp",c:"c",sh:"bash",bash:"bash",sql:"sql",html:"html",css:"css",json:"json",yaml:"yaml",yml:"yaml",xml:"xml"};function CT(e){var r;if(!e)return null;const t=(r=e.split(".").pop())==null?void 0:r.toLowerCase();return t&&kT[t]||null}function vp(e){switch(e){case"critical":return"bg-red-500";case"high":return"bg-orange-500";case"medium":return"bg-yellow-500";default:return"bg-blue-500"}}async function _p(e){try{await navigator.clipboard.writeText(e)}catch{const t=document.createElement("textarea");t.value=e,t.style.position="absolute",t.style.left="-9999px",document.body.appendChild(t),t.select(),document.execCommand("copy"),document.body.removeChild(t)}}const $u="https://app.strix.ai/api/auth/signup",TT="https://strix.ai/pricing",AT="ref=oss_viewer&utm_source=oss_viewer&utm_medium=local_viewer&utm_campaign=oss_viewer";function fa(e,t){const r=e.includes("?")?"&":"?";return`${e}${r}${AT}&utm_content=${encodeURIComponent(t)}`}function Tr(e,t={}){try{const r={event:e};for(const[s,o]of Object.entries(t))o!==void 0&&(r[s]=o);const a=JSON.stringify(r);typeof navigator<"u"&&navigator.sendBeacon?navigator.sendBeacon("/api/event",a):fetch("/api/event",{method:"POST",body:a,keepalive:!0})}catch{}}function jr(e,t){Tr("cta_clicked",{cta:e,surface:t})}function H_(e){var t,r,a="";if(typeof e=="string"||typeof e=="number")a+=e;else if(typeof e=="object")if(Array.isArray(e)){var s=e.length;for(t=0;t{const r=new Array(e.length+t.length);for(let a=0;a({classGroupId:e,validator:t}),$_=(e=new Map,t=null,r)=>({nextPart:e,validators:t,classGroupId:r}),vu="-",iy=[],jT="arbitrary..",DT=e=>{const t=zT(e),{conflictingClassGroups:r,conflictingClassGroupModifiers:a}=e;return{getClassGroupId:c=>{if(c.startsWith("[")&&c.endsWith("]"))return LT(c);const d=c.split(vu),h=d[0]===""&&d.length>1?1:0;return q_(d,h,t)},getConflictingClassGroupIds:(c,d)=>{if(d){const h=a[c],f=r[c];return h?f?OT(f,h):h:f||iy}return r[c]||iy}}},q_=(e,t,r)=>{if(e.length-t===0)return r.classGroupId;const s=e[t],o=r.nextPart.get(s);if(o){const f=q_(e,t+1,o);if(f)return f}const c=r.validators;if(c===null)return;const d=t===0?e.join(vu):e.slice(t).join(vu),h=c.length;for(let f=0;fe.slice(1,-1).indexOf(":")===-1?void 0:(()=>{const t=e.slice(1,-1),r=t.indexOf(":"),a=t.slice(0,r);return a?jT+a:void 0})(),zT=e=>{const{theme:t,classGroups:r}=e;return IT(r,t)},IT=(e,t)=>{const r=$_();for(const a in e){const s=e[a];wp(s,r,a,t)}return r},wp=(e,t,r,a)=>{const s=e.length;for(let o=0;o{if(typeof e=="string"){UT(e,t,r);return}if(typeof e=="function"){HT(e,t,r,a);return}$T(e,t,r,a)},UT=(e,t,r)=>{const a=e===""?t:P_(t,e);a.classGroupId=r},HT=(e,t,r,a)=>{if(qT(e)){wp(e(a),t,r,a);return}t.validators===null&&(t.validators=[]),t.validators.push(RT(r,e))},$T=(e,t,r,a)=>{const s=Object.entries(e),o=s.length;for(let c=0;c{let r=e;const a=t.split(vu),s=a.length;for(let o=0;o"isThemeGetter"in e&&e.isThemeGetter===!0,PT=e=>{if(e<1)return{get:()=>{},set:()=>{}};let t=0,r=Object.create(null),a=Object.create(null);const s=(o,c)=>{r[o]=c,t++,t>e&&(t=0,a=r,r=Object.create(null))};return{get(o){let c=r[o];if(c!==void 0)return c;if((c=a[o])!==void 0)return s(o,c),c},set(o,c){o in r?r[o]=c:s(o,c)}}},Hm="!",ay=":",FT=[],sy=(e,t,r,a,s)=>({modifiers:e,hasImportantModifier:t,baseClassName:r,maybePostfixModifierPosition:a,isExternal:s}),GT=e=>{const{prefix:t,experimentalParseClassName:r}=e;let a=s=>{const o=[];let c=0,d=0,h=0,f;const m=s.length;for(let N=0;Nh?f-h:void 0;return sy(o,x,y,_)};if(t){const s=t+ay,o=a;a=c=>c.startsWith(s)?o(c.slice(s.length)):sy(FT,!1,c,void 0,!0)}if(r){const s=a;a=o=>r({className:o,parseClassName:s})}return a},VT=e=>{const t=new Map;return e.orderSensitiveModifiers.forEach((r,a)=>{t.set(r,1e6+a)}),r=>{const a=[];let s=[];for(let o=0;o0&&(s.sort(),a.push(...s),s=[]),a.push(c)):s.push(c)}return s.length>0&&(s.sort(),a.push(...s)),a}},YT=e=>({cache:PT(e.cacheSize),parseClassName:GT(e),sortModifiers:VT(e),postfixLookupClassGroupIds:XT(e),...DT(e)}),XT=e=>{const t=Object.create(null),r=e.postfixLookupClassGroups;if(r)for(let a=0;a{const{parseClassName:r,getClassGroupId:a,getConflictingClassGroupIds:s,sortModifiers:o,postfixLookupClassGroupIds:c}=t,d=[],h=e.trim().split(KT);let f="";for(let m=h.length-1;m>=0;m-=1){const p=h[m],{isExternal:y,modifiers:x,hasImportantModifier:_,baseClassName:N,maybePostfixModifierPosition:S}=r(p);if(y){f=p+(f.length>0?" "+f:f);continue}let w=!!S,k;if(w){const U=N.substring(0,S);k=a(U);const B=k&&c[k]?a(N):void 0;B&&B!==k&&(k=B,w=!1)}else k=a(N);if(!k){if(!w){f=p+(f.length>0?" "+f:f);continue}if(k=a(N),!k){f=p+(f.length>0?" "+f:f);continue}w=!1}const E=x.length===0?"":x.length===1?x[0]:o(x).join(":"),M=_?E+Hm:E,I=M+k;if(d.indexOf(I)>-1)continue;d.push(I);const R=s(k,w);for(let U=0;U0?" "+f:f)}return f},QT=(...e)=>{let t=0,r,a,s="";for(;t{if(typeof e=="string")return e;let t,r="";for(let a=0;a{let r,a,s,o;const c=h=>{const f=t.reduce((m,p)=>p(m),e());return r=YT(f),a=r.cache.get,s=r.cache.set,o=d,d(h)},d=h=>{const f=a(h);if(f)return f;const m=ZT(h,r);return s(h,m),m};return o=c,(...h)=>o(QT(...h))},JT=[],fn=e=>{const t=r=>r[e]||JT;return t.isThemeGetter=!0,t},G_=/^\[(?:(\w[\w-]*):)?(.+)\]$/i,V_=/^\((?:(\w[\w-]*):)?(.+)\)$/i,eA=/^\d+(?:\.\d+)?\/\d+(?:\.\d+)?$/,tA=/^(\d+(\.\d+)?)?(xs|sm|md|lg|xl)$/,nA=/\d+(%|px|r?em|[sdl]?v([hwib]|min|max)|pt|pc|in|cm|mm|cap|ch|ex|r?lh|cq(w|h|i|b|min|max))|\b(calc|min|max|clamp)\(.+\)|^0$/,rA=/^(rgba?|hsla?|hwb|(ok)?(lab|lch)|color-mix)\(.+\)$/,iA=/^(inset_)?-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/,aA=/^(url|image|image-set|cross-fade|element|(repeating-)?(linear|radial|conic)-gradient)\(.+\)$/,ra=e=>eA.test(e),We=e=>!!e&&!Number.isNaN(Number(e)),Fr=e=>!!e&&Number.isInteger(Number(e)),mh=e=>e.endsWith("%")&&We(e.slice(0,-1)),bi=e=>tA.test(e),Y_=()=>!0,sA=e=>nA.test(e)&&!rA.test(e),Ep=()=>!1,lA=e=>iA.test(e),oA=e=>aA.test(e),cA=e=>!ke(e)&&!Ce(e),uA=e=>e.startsWith("@container")&&(e[10]==="/"&&e[11]!==void 0||e[11]==="s"&&e[16]!==void 0&&e.startsWith("-size/",10)||e[11]==="n"&&e[18]!==void 0&&e.startsWith("-normal/",10)),dA=e=>ha(e,Z_,Ep),ke=e=>G_.test(e),La=e=>ha(e,Q_,sA),ly=e=>ha(e,yA,We),fA=e=>ha(e,J_,Y_),hA=e=>ha(e,W_,Ep),oy=e=>ha(e,X_,Ep),mA=e=>ha(e,K_,oA),Qc=e=>ha(e,ew,lA),Ce=e=>V_.test(e),Kl=e=>Qa(e,Q_),pA=e=>Qa(e,W_),cy=e=>Qa(e,X_),gA=e=>Qa(e,Z_),bA=e=>Qa(e,K_),Wc=e=>Qa(e,ew,!0),xA=e=>Qa(e,J_,!0),ha=(e,t,r)=>{const a=G_.exec(e);return a?a[1]?t(a[1]):r(a[2]):!1},Qa=(e,t,r=!1)=>{const a=V_.exec(e);return a?a[1]?t(a[1]):r:!1},X_=e=>e==="position"||e==="percentage",K_=e=>e==="image"||e==="url",Z_=e=>e==="length"||e==="size"||e==="bg-size",Q_=e=>e==="length",yA=e=>e==="number",W_=e=>e==="family-name",J_=e=>e==="number"||e==="weight",ew=e=>e==="shadow",vA=()=>{const e=fn("color"),t=fn("font"),r=fn("text"),a=fn("font-weight"),s=fn("tracking"),o=fn("leading"),c=fn("breakpoint"),d=fn("container"),h=fn("spacing"),f=fn("radius"),m=fn("shadow"),p=fn("inset-shadow"),y=fn("text-shadow"),x=fn("drop-shadow"),_=fn("blur"),N=fn("perspective"),S=fn("aspect"),w=fn("ease"),k=fn("animate"),E=()=>["auto","avoid","all","avoid-page","page","left","right","column"],M=()=>["center","top","bottom","left","right","top-left","left-top","top-right","right-top","bottom-right","right-bottom","bottom-left","left-bottom"],I=()=>[...M(),Ce,ke],R=()=>["auto","hidden","clip","visible","scroll"],U=()=>["auto","contain","none"],B=()=>[Ce,ke,h],Z=()=>[ra,"full","auto",...B()],j=()=>[Fr,"none","subgrid",Ce,ke],z=()=>["auto",{span:["full",Fr,Ce,ke]},Fr,Ce,ke],V=()=>[Fr,"auto",Ce,ke],P=()=>["auto","min","max","fr",Ce,ke],T=()=>["start","end","center","between","around","evenly","stretch","baseline","center-safe","end-safe"],$=()=>["start","end","center","stretch","center-safe","end-safe"],O=()=>["auto",...B()],H=()=>[ra,"auto","full","dvw","dvh","lvw","lvh","svw","svh","min","max","fit",...B()],X=()=>[ra,"screen","full","dvw","lvw","svw","min","max","fit",...B()],K=()=>[ra,"screen","full","lh","dvh","lvh","svh","min","max","fit",...B()],C=()=>[e,Ce,ke],D=()=>[...M(),cy,oy,{position:[Ce,ke]}],Y=()=>["no-repeat",{repeat:["","x","y","space","round"]}],L=()=>["auto","cover","contain",gA,dA,{size:[Ce,ke]}],G=()=>[mh,Kl,La],q=()=>["","none","full",f,Ce,ke],Q=()=>["",We,Kl,La],J=()=>["solid","dashed","dotted","double"],W=()=>["normal","multiply","screen","overlay","darken","lighten","color-dodge","color-burn","hard-light","soft-light","difference","exclusion","hue","saturation","color","luminosity"],te=()=>[We,mh,cy,oy],ce=()=>["","none",_,Ce,ke],fe=()=>["none",We,Ce,ke],be=()=>["none",We,Ce,ke],we=()=>[We,Ce,ke],Ne=()=>[ra,"full",...B()];return{cacheSize:500,theme:{animate:["spin","ping","pulse","bounce"],aspect:["video"],blur:[bi],breakpoint:[bi],color:[Y_],container:[bi],"drop-shadow":[bi],ease:["in","out","in-out"],font:[cA],"font-weight":["thin","extralight","light","normal","medium","semibold","bold","extrabold","black"],"inset-shadow":[bi],leading:["none","tight","snug","normal","relaxed","loose"],perspective:["dramatic","near","normal","midrange","distant","none"],radius:[bi],shadow:[bi],spacing:["px",We],text:[bi],"text-shadow":[bi],tracking:["tighter","tight","normal","wide","wider","widest"]},classGroups:{aspect:[{aspect:["auto","square",ra,ke,Ce,S]}],container:["container"],"container-type":[{"@container":["","normal","size",Ce,ke]}],"container-named":[uA],columns:[{columns:[We,ke,Ce,d]}],"break-after":[{"break-after":E()}],"break-before":[{"break-before":E()}],"break-inside":[{"break-inside":["auto","avoid","avoid-page","avoid-column"]}],"box-decoration":[{"box-decoration":["slice","clone"]}],box:[{box:["border","content"]}],display:["block","inline-block","inline","flex","inline-flex","table","inline-table","table-caption","table-cell","table-column","table-column-group","table-footer-group","table-header-group","table-row-group","table-row","flow-root","grid","inline-grid","contents","list-item","hidden"],sr:["sr-only","not-sr-only"],float:[{float:["right","left","none","start","end"]}],clear:[{clear:["left","right","both","none","start","end"]}],isolation:["isolate","isolation-auto"],"object-fit":[{object:["contain","cover","fill","none","scale-down"]}],"object-position":[{object:I()}],overflow:[{overflow:R()}],"overflow-x":[{"overflow-x":R()}],"overflow-y":[{"overflow-y":R()}],overscroll:[{overscroll:U()}],"overscroll-x":[{"overscroll-x":U()}],"overscroll-y":[{"overscroll-y":U()}],position:["static","fixed","absolute","relative","sticky"],inset:[{inset:Z()}],"inset-x":[{"inset-x":Z()}],"inset-y":[{"inset-y":Z()}],start:[{"inset-s":Z(),start:Z()}],end:[{"inset-e":Z(),end:Z()}],"inset-bs":[{"inset-bs":Z()}],"inset-be":[{"inset-be":Z()}],top:[{top:Z()}],right:[{right:Z()}],bottom:[{bottom:Z()}],left:[{left:Z()}],visibility:["visible","invisible","collapse"],z:[{z:[Fr,"auto",Ce,ke]}],basis:[{basis:[ra,"full","auto",d,...B()]}],"flex-direction":[{flex:["row","row-reverse","col","col-reverse"]}],"flex-wrap":[{flex:["nowrap","wrap","wrap-reverse"]}],flex:[{flex:[We,ra,"auto","initial","none",ke]}],grow:[{grow:["",We,Ce,ke]}],shrink:[{shrink:["",We,Ce,ke]}],order:[{order:[Fr,"first","last","none",Ce,ke]}],"grid-cols":[{"grid-cols":j()}],"col-start-end":[{col:z()}],"col-start":[{"col-start":V()}],"col-end":[{"col-end":V()}],"grid-rows":[{"grid-rows":j()}],"row-start-end":[{row:z()}],"row-start":[{"row-start":V()}],"row-end":[{"row-end":V()}],"grid-flow":[{"grid-flow":["row","col","dense","row-dense","col-dense"]}],"auto-cols":[{"auto-cols":P()}],"auto-rows":[{"auto-rows":P()}],gap:[{gap:B()}],"gap-x":[{"gap-x":B()}],"gap-y":[{"gap-y":B()}],"justify-content":[{justify:[...T(),"normal"]}],"justify-items":[{"justify-items":[...$(),"normal"]}],"justify-self":[{"justify-self":["auto",...$()]}],"align-content":[{content:["normal",...T()]}],"align-items":[{items:[...$(),{baseline:["","last"]}]}],"align-self":[{self:["auto",...$(),{baseline:["","last"]}]}],"place-content":[{"place-content":T()}],"place-items":[{"place-items":[...$(),"baseline"]}],"place-self":[{"place-self":["auto",...$()]}],p:[{p:B()}],px:[{px:B()}],py:[{py:B()}],ps:[{ps:B()}],pe:[{pe:B()}],pbs:[{pbs:B()}],pbe:[{pbe:B()}],pt:[{pt:B()}],pr:[{pr:B()}],pb:[{pb:B()}],pl:[{pl:B()}],m:[{m:O()}],mx:[{mx:O()}],my:[{my:O()}],ms:[{ms:O()}],me:[{me:O()}],mbs:[{mbs:O()}],mbe:[{mbe:O()}],mt:[{mt:O()}],mr:[{mr:O()}],mb:[{mb:O()}],ml:[{ml:O()}],"space-x":[{"space-x":B()}],"space-x-reverse":["space-x-reverse"],"space-y":[{"space-y":B()}],"space-y-reverse":["space-y-reverse"],size:[{size:H()}],"inline-size":[{inline:["auto",...X()]}],"min-inline-size":[{"min-inline":["auto",...X()]}],"max-inline-size":[{"max-inline":["none",...X()]}],"block-size":[{block:["auto",...K()]}],"min-block-size":[{"min-block":["auto",...K()]}],"max-block-size":[{"max-block":["none",...K()]}],w:[{w:[d,"screen",...H()]}],"min-w":[{"min-w":[d,"screen","none",...H()]}],"max-w":[{"max-w":[d,"screen","none","prose",{screen:[c]},...H()]}],h:[{h:["screen","lh",...H()]}],"min-h":[{"min-h":["screen","lh","none",...H()]}],"max-h":[{"max-h":["screen","lh",...H()]}],"font-size":[{text:["base",r,Kl,La]}],"font-smoothing":["antialiased","subpixel-antialiased"],"font-style":["italic","not-italic"],"font-weight":[{font:[a,xA,fA]}],"font-stretch":[{"font-stretch":["ultra-condensed","extra-condensed","condensed","semi-condensed","normal","semi-expanded","expanded","extra-expanded","ultra-expanded",mh,ke]}],"font-family":[{font:[pA,hA,t]}],"font-features":[{"font-features":[ke]}],"fvn-normal":["normal-nums"],"fvn-ordinal":["ordinal"],"fvn-slashed-zero":["slashed-zero"],"fvn-figure":["lining-nums","oldstyle-nums"],"fvn-spacing":["proportional-nums","tabular-nums"],"fvn-fraction":["diagonal-fractions","stacked-fractions"],tracking:[{tracking:[s,Ce,ke]}],"line-clamp":[{"line-clamp":[We,"none",Ce,ly]}],leading:[{leading:[o,...B()]}],"list-image":[{"list-image":["none",Ce,ke]}],"list-style-position":[{list:["inside","outside"]}],"list-style-type":[{list:["disc","decimal","none",Ce,ke]}],"text-alignment":[{text:["left","center","right","justify","start","end"]}],"placeholder-color":[{placeholder:C()}],"text-color":[{text:C()}],"text-decoration":["underline","overline","line-through","no-underline"],"text-decoration-style":[{decoration:[...J(),"wavy"]}],"text-decoration-thickness":[{decoration:[We,"from-font","auto",Ce,La]}],"text-decoration-color":[{decoration:C()}],"underline-offset":[{"underline-offset":[We,"auto",Ce,ke]}],"text-transform":["uppercase","lowercase","capitalize","normal-case"],"text-overflow":["truncate","text-ellipsis","text-clip"],"text-wrap":[{text:["wrap","nowrap","balance","pretty"]}],indent:[{indent:B()}],"tab-size":[{tab:[Fr,Ce,ke]}],"vertical-align":[{align:["baseline","top","middle","bottom","text-top","text-bottom","sub","super",Ce,ke]}],whitespace:[{whitespace:["normal","nowrap","pre","pre-line","pre-wrap","break-spaces"]}],break:[{break:["normal","words","all","keep"]}],wrap:[{wrap:["break-word","anywhere","normal"]}],hyphens:[{hyphens:["none","manual","auto"]}],content:[{content:["none",Ce,ke]}],"bg-attachment":[{bg:["fixed","local","scroll"]}],"bg-clip":[{"bg-clip":["border","padding","content","text"]}],"bg-origin":[{"bg-origin":["border","padding","content"]}],"bg-position":[{bg:D()}],"bg-repeat":[{bg:Y()}],"bg-size":[{bg:L()}],"bg-image":[{bg:["none",{linear:[{to:["t","tr","r","br","b","bl","l","tl"]},Fr,Ce,ke],radial:["",Ce,ke],conic:[Fr,Ce,ke]},bA,mA]}],"bg-color":[{bg:C()}],"gradient-from-pos":[{from:G()}],"gradient-via-pos":[{via:G()}],"gradient-to-pos":[{to:G()}],"gradient-from":[{from:C()}],"gradient-via":[{via:C()}],"gradient-to":[{to:C()}],rounded:[{rounded:q()}],"rounded-s":[{"rounded-s":q()}],"rounded-e":[{"rounded-e":q()}],"rounded-t":[{"rounded-t":q()}],"rounded-r":[{"rounded-r":q()}],"rounded-b":[{"rounded-b":q()}],"rounded-l":[{"rounded-l":q()}],"rounded-ss":[{"rounded-ss":q()}],"rounded-se":[{"rounded-se":q()}],"rounded-ee":[{"rounded-ee":q()}],"rounded-es":[{"rounded-es":q()}],"rounded-tl":[{"rounded-tl":q()}],"rounded-tr":[{"rounded-tr":q()}],"rounded-br":[{"rounded-br":q()}],"rounded-bl":[{"rounded-bl":q()}],"border-w":[{border:Q()}],"border-w-x":[{"border-x":Q()}],"border-w-y":[{"border-y":Q()}],"border-w-s":[{"border-s":Q()}],"border-w-e":[{"border-e":Q()}],"border-w-bs":[{"border-bs":Q()}],"border-w-be":[{"border-be":Q()}],"border-w-t":[{"border-t":Q()}],"border-w-r":[{"border-r":Q()}],"border-w-b":[{"border-b":Q()}],"border-w-l":[{"border-l":Q()}],"divide-x":[{"divide-x":Q()}],"divide-x-reverse":["divide-x-reverse"],"divide-y":[{"divide-y":Q()}],"divide-y-reverse":["divide-y-reverse"],"border-style":[{border:[...J(),"hidden","none"]}],"divide-style":[{divide:[...J(),"hidden","none"]}],"border-color":[{border:C()}],"border-color-x":[{"border-x":C()}],"border-color-y":[{"border-y":C()}],"border-color-s":[{"border-s":C()}],"border-color-e":[{"border-e":C()}],"border-color-bs":[{"border-bs":C()}],"border-color-be":[{"border-be":C()}],"border-color-t":[{"border-t":C()}],"border-color-r":[{"border-r":C()}],"border-color-b":[{"border-b":C()}],"border-color-l":[{"border-l":C()}],"divide-color":[{divide:C()}],"outline-style":[{outline:[...J(),"none","hidden"]}],"outline-offset":[{"outline-offset":[We,Ce,ke]}],"outline-w":[{outline:["",We,Kl,La]}],"outline-color":[{outline:C()}],shadow:[{shadow:["","none",m,Wc,Qc]}],"shadow-color":[{shadow:C()}],"inset-shadow":[{"inset-shadow":["none",p,Wc,Qc]}],"inset-shadow-color":[{"inset-shadow":C()}],"ring-w":[{ring:Q()}],"ring-w-inset":["ring-inset"],"ring-color":[{ring:C()}],"ring-offset-w":[{"ring-offset":[We,La]}],"ring-offset-color":[{"ring-offset":C()}],"inset-ring-w":[{"inset-ring":Q()}],"inset-ring-color":[{"inset-ring":C()}],"text-shadow":[{"text-shadow":["none",y,Wc,Qc]}],"text-shadow-color":[{"text-shadow":C()}],opacity:[{opacity:[We,Ce,ke]}],"mix-blend":[{"mix-blend":[...W(),"plus-darker","plus-lighter"]}],"bg-blend":[{"bg-blend":W()}],"mask-clip":[{"mask-clip":["border","padding","content","fill","stroke","view"]},"mask-no-clip"],"mask-composite":[{mask:["add","subtract","intersect","exclude"]}],"mask-image-linear-pos":[{"mask-linear":[We]}],"mask-image-linear-from-pos":[{"mask-linear-from":te()}],"mask-image-linear-to-pos":[{"mask-linear-to":te()}],"mask-image-linear-from-color":[{"mask-linear-from":C()}],"mask-image-linear-to-color":[{"mask-linear-to":C()}],"mask-image-t-from-pos":[{"mask-t-from":te()}],"mask-image-t-to-pos":[{"mask-t-to":te()}],"mask-image-t-from-color":[{"mask-t-from":C()}],"mask-image-t-to-color":[{"mask-t-to":C()}],"mask-image-r-from-pos":[{"mask-r-from":te()}],"mask-image-r-to-pos":[{"mask-r-to":te()}],"mask-image-r-from-color":[{"mask-r-from":C()}],"mask-image-r-to-color":[{"mask-r-to":C()}],"mask-image-b-from-pos":[{"mask-b-from":te()}],"mask-image-b-to-pos":[{"mask-b-to":te()}],"mask-image-b-from-color":[{"mask-b-from":C()}],"mask-image-b-to-color":[{"mask-b-to":C()}],"mask-image-l-from-pos":[{"mask-l-from":te()}],"mask-image-l-to-pos":[{"mask-l-to":te()}],"mask-image-l-from-color":[{"mask-l-from":C()}],"mask-image-l-to-color":[{"mask-l-to":C()}],"mask-image-x-from-pos":[{"mask-x-from":te()}],"mask-image-x-to-pos":[{"mask-x-to":te()}],"mask-image-x-from-color":[{"mask-x-from":C()}],"mask-image-x-to-color":[{"mask-x-to":C()}],"mask-image-y-from-pos":[{"mask-y-from":te()}],"mask-image-y-to-pos":[{"mask-y-to":te()}],"mask-image-y-from-color":[{"mask-y-from":C()}],"mask-image-y-to-color":[{"mask-y-to":C()}],"mask-image-radial":[{"mask-radial":[Ce,ke]}],"mask-image-radial-from-pos":[{"mask-radial-from":te()}],"mask-image-radial-to-pos":[{"mask-radial-to":te()}],"mask-image-radial-from-color":[{"mask-radial-from":C()}],"mask-image-radial-to-color":[{"mask-radial-to":C()}],"mask-image-radial-shape":[{"mask-radial":["circle","ellipse"]}],"mask-image-radial-size":[{"mask-radial":[{closest:["side","corner"],farthest:["side","corner"]}]}],"mask-image-radial-pos":[{"mask-radial-at":M()}],"mask-image-conic-pos":[{"mask-conic":[We]}],"mask-image-conic-from-pos":[{"mask-conic-from":te()}],"mask-image-conic-to-pos":[{"mask-conic-to":te()}],"mask-image-conic-from-color":[{"mask-conic-from":C()}],"mask-image-conic-to-color":[{"mask-conic-to":C()}],"mask-mode":[{mask:["alpha","luminance","match"]}],"mask-origin":[{"mask-origin":["border","padding","content","fill","stroke","view"]}],"mask-position":[{mask:D()}],"mask-repeat":[{mask:Y()}],"mask-size":[{mask:L()}],"mask-type":[{"mask-type":["alpha","luminance"]}],"mask-image":[{mask:["none",Ce,ke]}],filter:[{filter:["","none",Ce,ke]}],blur:[{blur:ce()}],brightness:[{brightness:[We,Ce,ke]}],contrast:[{contrast:[We,Ce,ke]}],"drop-shadow":[{"drop-shadow":["","none",x,Wc,Qc]}],"drop-shadow-color":[{"drop-shadow":C()}],grayscale:[{grayscale:["",We,Ce,ke]}],"hue-rotate":[{"hue-rotate":[We,Ce,ke]}],invert:[{invert:["",We,Ce,ke]}],saturate:[{saturate:[We,Ce,ke]}],sepia:[{sepia:["",We,Ce,ke]}],"backdrop-filter":[{"backdrop-filter":["","none",Ce,ke]}],"backdrop-blur":[{"backdrop-blur":ce()}],"backdrop-brightness":[{"backdrop-brightness":[We,Ce,ke]}],"backdrop-contrast":[{"backdrop-contrast":[We,Ce,ke]}],"backdrop-grayscale":[{"backdrop-grayscale":["",We,Ce,ke]}],"backdrop-hue-rotate":[{"backdrop-hue-rotate":[We,Ce,ke]}],"backdrop-invert":[{"backdrop-invert":["",We,Ce,ke]}],"backdrop-opacity":[{"backdrop-opacity":[We,Ce,ke]}],"backdrop-saturate":[{"backdrop-saturate":[We,Ce,ke]}],"backdrop-sepia":[{"backdrop-sepia":["",We,Ce,ke]}],"border-collapse":[{border:["collapse","separate"]}],"border-spacing":[{"border-spacing":B()}],"border-spacing-x":[{"border-spacing-x":B()}],"border-spacing-y":[{"border-spacing-y":B()}],"table-layout":[{table:["auto","fixed"]}],caption:[{caption:["top","bottom"]}],transition:[{transition:["","all","colors","opacity","shadow","transform","none",Ce,ke]}],"transition-behavior":[{transition:["normal","discrete"]}],duration:[{duration:[We,"initial",Ce,ke]}],ease:[{ease:["linear","initial",w,Ce,ke]}],delay:[{delay:[We,Ce,ke]}],animate:[{animate:["none",k,Ce,ke]}],backface:[{backface:["hidden","visible"]}],perspective:[{perspective:[N,Ce,ke]}],"perspective-origin":[{"perspective-origin":I()}],rotate:[{rotate:fe()}],"rotate-x":[{"rotate-x":fe()}],"rotate-y":[{"rotate-y":fe()}],"rotate-z":[{"rotate-z":fe()}],scale:[{scale:be()}],"scale-x":[{"scale-x":be()}],"scale-y":[{"scale-y":be()}],"scale-z":[{"scale-z":be()}],"scale-3d":["scale-3d"],skew:[{skew:we()}],"skew-x":[{"skew-x":we()}],"skew-y":[{"skew-y":we()}],transform:[{transform:[Ce,ke,"","none","gpu","cpu"]}],"transform-origin":[{origin:I()}],"transform-style":[{transform:["3d","flat"]}],translate:[{translate:Ne()}],"translate-x":[{"translate-x":Ne()}],"translate-y":[{"translate-y":Ne()}],"translate-z":[{"translate-z":Ne()}],"translate-none":["translate-none"],zoom:[{zoom:[Fr,Ce,ke]}],accent:[{accent:C()}],appearance:[{appearance:["none","auto"]}],"caret-color":[{caret:C()}],"color-scheme":[{scheme:["normal","dark","light","light-dark","only-dark","only-light"]}],cursor:[{cursor:["auto","default","pointer","wait","text","move","help","not-allowed","none","context-menu","progress","cell","crosshair","vertical-text","alias","copy","no-drop","grab","grabbing","all-scroll","col-resize","row-resize","n-resize","e-resize","s-resize","w-resize","ne-resize","nw-resize","se-resize","sw-resize","ew-resize","ns-resize","nesw-resize","nwse-resize","zoom-in","zoom-out",Ce,ke]}],"field-sizing":[{"field-sizing":["fixed","content"]}],"pointer-events":[{"pointer-events":["auto","none"]}],resize:[{resize:["none","","y","x"]}],"scroll-behavior":[{scroll:["auto","smooth"]}],"scrollbar-thumb-color":[{"scrollbar-thumb":C()}],"scrollbar-track-color":[{"scrollbar-track":C()}],"scrollbar-gutter":[{"scrollbar-gutter":["auto","stable","both"]}],"scrollbar-w":[{scrollbar:["auto","thin","none"]}],"scroll-m":[{"scroll-m":B()}],"scroll-mx":[{"scroll-mx":B()}],"scroll-my":[{"scroll-my":B()}],"scroll-ms":[{"scroll-ms":B()}],"scroll-me":[{"scroll-me":B()}],"scroll-mbs":[{"scroll-mbs":B()}],"scroll-mbe":[{"scroll-mbe":B()}],"scroll-mt":[{"scroll-mt":B()}],"scroll-mr":[{"scroll-mr":B()}],"scroll-mb":[{"scroll-mb":B()}],"scroll-ml":[{"scroll-ml":B()}],"scroll-p":[{"scroll-p":B()}],"scroll-px":[{"scroll-px":B()}],"scroll-py":[{"scroll-py":B()}],"scroll-ps":[{"scroll-ps":B()}],"scroll-pe":[{"scroll-pe":B()}],"scroll-pbs":[{"scroll-pbs":B()}],"scroll-pbe":[{"scroll-pbe":B()}],"scroll-pt":[{"scroll-pt":B()}],"scroll-pr":[{"scroll-pr":B()}],"scroll-pb":[{"scroll-pb":B()}],"scroll-pl":[{"scroll-pl":B()}],"snap-align":[{snap:["start","end","center","align-none"]}],"snap-stop":[{snap:["normal","always"]}],"snap-type":[{snap:["none","x","y","both"]}],"snap-strictness":[{snap:["mandatory","proximity"]}],touch:[{touch:["auto","none","manipulation"]}],"touch-x":[{"touch-pan":["x","left","right"]}],"touch-y":[{"touch-pan":["y","up","down"]}],"touch-pz":["touch-pinch-zoom"],select:[{select:["none","text","all","auto"]}],"will-change":[{"will-change":["auto","scroll","contents","transform",Ce,ke]}],fill:[{fill:["none",...C()]}],"stroke-w":[{stroke:[We,Kl,La,ly]}],stroke:[{stroke:["none",...C()]}],"forced-color-adjust":[{"forced-color-adjust":["auto","none"]}]},conflictingClassGroups:{"container-named":["container-type"],overflow:["overflow-x","overflow-y"],overscroll:["overscroll-x","overscroll-y"],inset:["inset-x","inset-y","inset-bs","inset-be","start","end","top","right","bottom","left"],"inset-x":["right","left"],"inset-y":["top","bottom"],flex:["basis","grow","shrink"],gap:["gap-x","gap-y"],p:["px","py","ps","pe","pbs","pbe","pt","pr","pb","pl"],px:["pr","pl"],py:["pt","pb"],m:["mx","my","ms","me","mbs","mbe","mt","mr","mb","ml"],mx:["mr","ml"],my:["mt","mb"],size:["w","h"],"font-size":["leading"],"fvn-normal":["fvn-ordinal","fvn-slashed-zero","fvn-figure","fvn-spacing","fvn-fraction"],"fvn-ordinal":["fvn-normal"],"fvn-slashed-zero":["fvn-normal"],"fvn-figure":["fvn-normal"],"fvn-spacing":["fvn-normal"],"fvn-fraction":["fvn-normal"],"line-clamp":["display","overflow"],rounded:["rounded-s","rounded-e","rounded-t","rounded-r","rounded-b","rounded-l","rounded-ss","rounded-se","rounded-ee","rounded-es","rounded-tl","rounded-tr","rounded-br","rounded-bl"],"rounded-s":["rounded-ss","rounded-es"],"rounded-e":["rounded-se","rounded-ee"],"rounded-t":["rounded-tl","rounded-tr"],"rounded-r":["rounded-tr","rounded-br"],"rounded-b":["rounded-br","rounded-bl"],"rounded-l":["rounded-tl","rounded-bl"],"border-spacing":["border-spacing-x","border-spacing-y"],"border-w":["border-w-x","border-w-y","border-w-s","border-w-e","border-w-bs","border-w-be","border-w-t","border-w-r","border-w-b","border-w-l"],"border-w-x":["border-w-r","border-w-l"],"border-w-y":["border-w-t","border-w-b"],"border-color":["border-color-x","border-color-y","border-color-s","border-color-e","border-color-bs","border-color-be","border-color-t","border-color-r","border-color-b","border-color-l"],"border-color-x":["border-color-r","border-color-l"],"border-color-y":["border-color-t","border-color-b"],translate:["translate-x","translate-y","translate-none"],"translate-none":["translate","translate-x","translate-y","translate-z"],"scroll-m":["scroll-mx","scroll-my","scroll-ms","scroll-me","scroll-mbs","scroll-mbe","scroll-mt","scroll-mr","scroll-mb","scroll-ml"],"scroll-mx":["scroll-mr","scroll-ml"],"scroll-my":["scroll-mt","scroll-mb"],"scroll-p":["scroll-px","scroll-py","scroll-ps","scroll-pe","scroll-pbs","scroll-pbe","scroll-pt","scroll-pr","scroll-pb","scroll-pl"],"scroll-px":["scroll-pr","scroll-pl"],"scroll-py":["scroll-pt","scroll-pb"],touch:["touch-x","touch-y","touch-pz"],"touch-x":["touch"],"touch-y":["touch"],"touch-pz":["touch"]},conflictingClassGroupModifiers:{"font-size":["leading"]},postfixLookupClassGroups:["container-type"],orderSensitiveModifiers:["*","**","after","backdrop","before","details-content","file","first-letter","first-line","marker","placeholder","selection"]}},_A=WT(vA);function Mr(...e){return _A(MT(e))}function wA(e){return new Date(e).toLocaleDateString("en-US",{year:"numeric",month:"short",day:"numeric"})}function $m(e){const t=new Date(e),a=Math.floor((new Date().getTime()-t.getTime())/1e3);return a<60?"just now":a<3600?`${Math.floor(a/60)}m ago`:a<86400?`${Math.floor(a/3600)}h ago`:a<604800?`${Math.floor(a/86400)}d ago`:wA(e)}function EA(e){return`STRIX-${e}`}function Ds(e){return new Intl.NumberFormat("en-US").format(e)}function NA(e,t){const r={};return(e[e.length-1]===""?[...e,""]:e).join((r.padRight?" ":"")+","+(r.padLeft===!1?"":" ")).trim()}const SA=/^[$_\p{ID_Start}][$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,kA=/^[$_\p{ID_Start}][-$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,CA={};function uy(e,t){return(CA.jsx?kA:SA).test(e)}const TA=/[ \t\n\f\r]/g;function AA(e){return typeof e=="object"?e.type==="text"?dy(e.value):!1:dy(e)}function dy(e){return e.replace(TA,"")===""}class Mo{constructor(t,r,a){this.normal=r,this.property=t,a&&(this.space=a)}}Mo.prototype.normal={};Mo.prototype.property={};Mo.prototype.space=void 0;function tw(e,t){const r={},a={};for(const s of e)Object.assign(r,s.property),Object.assign(a,s.normal);return new Mo(r,a,t)}function qm(e){return e.toLowerCase()}class Vn{constructor(t,r){this.attribute=r,this.property=t}}Vn.prototype.attribute="";Vn.prototype.booleanish=!1;Vn.prototype.boolean=!1;Vn.prototype.commaOrSpaceSeparated=!1;Vn.prototype.commaSeparated=!1;Vn.prototype.defined=!1;Vn.prototype.mustUseProperty=!1;Vn.prototype.number=!1;Vn.prototype.overloadedBoolean=!1;Vn.prototype.property="";Vn.prototype.spaceSeparated=!1;Vn.prototype.space=void 0;let MA=0;const Ge=Wa(),an=Wa(),Pm=Wa(),ve=Wa(),Ct=Wa(),qa=Wa(),rr=Wa();function Wa(){return 2**++MA}const Fm=Object.freeze(Object.defineProperty({__proto__:null,boolean:Ge,booleanish:an,commaOrSpaceSeparated:rr,commaSeparated:qa,number:ve,overloadedBoolean:Pm,spaceSeparated:Ct},Symbol.toStringTag,{value:"Module"})),ph=Object.keys(Fm);class Np extends Vn{constructor(t,r,a,s){let o=-1;if(super(t,r),fy(this,"space",s),typeof a=="number")for(;++o4&&r.slice(0,4)==="data"&&LA.test(t)){if(t.charAt(4)==="-"){const o=t.slice(5).replace(hy,BA);a="data"+o.charAt(0).toUpperCase()+o.slice(1)}else{const o=t.slice(4);if(!hy.test(o)){let c=o.replace(DA,IA);c.charAt(0)!=="-"&&(c="-"+c),t="data"+c}}s=Np}return new s(a,t)}function IA(e){return"-"+e.toLowerCase()}function BA(e){return e.charAt(1).toUpperCase()}const UA=tw([nw,OA,aw,sw,lw],"html"),Sp=tw([nw,RA,aw,sw,lw],"svg");function HA(e){return e.join(" ").trim()}var Ls={},gh,my;function $A(){if(my)return gh;my=1;var e=/\/\*[^*]*\*+([^/*][^*]*\*+)*\//g,t=/\n/g,r=/^\s*/,a=/^(\*?[-#/*\\\w]+(\[[0-9a-z_-]+\])?)\s*/,s=/^:\s*/,o=/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^)]*?\)|[^};])+)/,c=/^[;\s]*/,d=/^\s+|\s+$/g,h=` -`,f="/",m="*",p="",y="comment",x="declaration";function _(S,w){if(typeof S!="string")throw new TypeError("First argument must be a string");if(!S)return[];w=w||{};var k=1,E=1;function M(T){var $=T.match(t);$&&(k+=$.length);var O=T.lastIndexOf(h);E=~O?T.length-O:E+T.length}function I(){var T={line:k,column:E};return function($){return $.position=new R(T),Z(),$}}function R(T){this.start=T,this.end={line:k,column:E},this.source=w.source}R.prototype.content=S;function U(T){var $=new Error(w.source+":"+k+":"+E+": "+T);if($.reason=T,$.filename=w.source,$.line=k,$.column=E,$.source=S,!w.silent)throw $}function B(T){var $=T.exec(S);if($){var O=$[0];return M(O),S=S.slice(O.length),$}}function Z(){B(r)}function j(T){var $;for(T=T||[];$=z();)$!==!1&&T.push($);return T}function z(){var T=I();if(!(f!=S.charAt(0)||m!=S.charAt(1))){for(var $=2;p!=S.charAt($)&&(m!=S.charAt($)||f!=S.charAt($+1));)++$;if($+=2,p===S.charAt($-1))return U("End of comment missing");var O=S.slice(2,$-2);return E+=2,M(O),S=S.slice($),E+=2,T({type:y,comment:O})}}function V(){var T=I(),$=B(a);if($){if(z(),!B(s))return U("property missing ':'");var O=B(o),H=T({type:x,property:N($[0].replace(e,p)),value:O?N(O[0].replace(e,p)):p});return B(c),H}}function P(){var T=[];j(T);for(var $;$=V();)$!==!1&&(T.push($),j(T));return T}return Z(),P()}function N(S){return S?S.replace(d,p):p}return gh=_,gh}var py;function qA(){if(py)return Ls;py=1;var e=Ls&&Ls.__importDefault||function(a){return a&&a.__esModule?a:{default:a}};Object.defineProperty(Ls,"__esModule",{value:!0}),Ls.default=r;const t=e($A());function r(a,s){let o=null;if(!a||typeof a!="string")return o;const c=(0,t.default)(a),d=typeof s=="function";return c.forEach(h=>{if(h.type!=="declaration")return;const{property:f,value:m}=h;d?s(f,m,h):m&&(o=o||{},o[f]=m)}),o}return Ls}var Zl={},gy;function PA(){if(gy)return Zl;gy=1,Object.defineProperty(Zl,"__esModule",{value:!0}),Zl.camelCase=void 0;var e=/^--[a-zA-Z0-9_-]+$/,t=/-([a-z])/g,r=/^[^-]+$/,a=/^-(webkit|moz|ms|o|khtml)-/,s=/^-(ms)-/,o=function(f){return!f||r.test(f)||e.test(f)},c=function(f,m){return m.toUpperCase()},d=function(f,m){return"".concat(m,"-")},h=function(f,m){return m===void 0&&(m={}),o(f)?f:(f=f.toLowerCase(),m.reactCompat?f=f.replace(s,d):f=f.replace(a,d),f.replace(t,c))};return Zl.camelCase=h,Zl}var Ql,by;function FA(){if(by)return Ql;by=1;var e=Ql&&Ql.__importDefault||function(s){return s&&s.__esModule?s:{default:s}},t=e(qA()),r=PA();function a(s,o){var c={};return!s||typeof s!="string"||(0,t.default)(s,function(d,h){d&&h&&(c[(0,r.camelCase)(d,o)]=h)}),c}return a.default=a,Ql=a,Ql}var GA=FA();const VA=Co(GA),ow=cw("end"),kp=cw("start");function cw(e){return t;function t(r){const a=r&&r.position&&r.position[e]||{};if(typeof a.line=="number"&&a.line>0&&typeof a.column=="number"&&a.column>0)return{line:a.line,column:a.column,offset:typeof a.offset=="number"&&a.offset>-1?a.offset:void 0}}}function YA(e){const t=kp(e),r=ow(e);if(t&&r)return{start:t,end:r}}function lo(e){return!e||typeof e!="object"?"":"position"in e||"type"in e?xy(e.position):"start"in e||"end"in e?xy(e):"line"in e||"column"in e?Gm(e):""}function Gm(e){return yy(e&&e.line)+":"+yy(e&&e.column)}function xy(e){return Gm(e&&e.start)+"-"+Gm(e&&e.end)}function yy(e){return e&&typeof e=="number"?e:1}class Mn extends Error{constructor(t,r,a){super(),typeof r=="string"&&(a=r,r=void 0);let s="",o={},c=!1;if(r&&("line"in r&&"column"in r?o={place:r}:"start"in r&&"end"in r?o={place:r}:"type"in r?o={ancestors:[r],place:r.position}:o={...r}),typeof t=="string"?s=t:!o.cause&&t&&(c=!0,s=t.message,o.cause=t),!o.ruleId&&!o.source&&typeof a=="string"){const h=a.indexOf(":");h===-1?o.ruleId=a:(o.source=a.slice(0,h),o.ruleId=a.slice(h+1))}if(!o.place&&o.ancestors&&o.ancestors){const h=o.ancestors[o.ancestors.length-1];h&&(o.place=h.position)}const d=o.place&&"start"in o.place?o.place.start:o.place;this.ancestors=o.ancestors||void 0,this.cause=o.cause||void 0,this.column=d?d.column:void 0,this.fatal=void 0,this.file="",this.message=s,this.line=d?d.line:void 0,this.name=lo(o.place)||"1:1",this.place=o.place||void 0,this.reason=this.message,this.ruleId=o.ruleId||void 0,this.source=o.source||void 0,this.stack=c&&o.cause&&typeof o.cause.stack=="string"?o.cause.stack:"",this.actual=void 0,this.expected=void 0,this.note=void 0,this.url=void 0}}Mn.prototype.file="";Mn.prototype.name="";Mn.prototype.reason="";Mn.prototype.message="";Mn.prototype.stack="";Mn.prototype.column=void 0;Mn.prototype.line=void 0;Mn.prototype.ancestors=void 0;Mn.prototype.cause=void 0;Mn.prototype.fatal=void 0;Mn.prototype.place=void 0;Mn.prototype.ruleId=void 0;Mn.prototype.source=void 0;const Cp={}.hasOwnProperty,XA=new Map,KA=/[A-Z]/g,ZA=new Set(["table","tbody","thead","tfoot","tr"]),QA=new Set(["td","th"]),uw="https://github.com/syntax-tree/hast-util-to-jsx-runtime";function WA(e,t){if(!t||t.Fragment===void 0)throw new TypeError("Expected `Fragment` in options");const r=t.filePath||void 0;let a;if(t.development){if(typeof t.jsxDEV!="function")throw new TypeError("Expected `jsxDEV` in options when `development: true`");a=sM(r,t.jsxDEV)}else{if(typeof t.jsx!="function")throw new TypeError("Expected `jsx` in production options");if(typeof t.jsxs!="function")throw new TypeError("Expected `jsxs` in production options");a=aM(r,t.jsx,t.jsxs)}const s={Fragment:t.Fragment,ancestors:[],components:t.components||{},create:a,elementAttributeNameCase:t.elementAttributeNameCase||"react",evaluater:t.createEvaluater?t.createEvaluater():void 0,filePath:r,ignoreInvalidStyle:t.ignoreInvalidStyle||!1,passKeys:t.passKeys!==!1,passNode:t.passNode||!1,schema:t.space==="svg"?Sp:UA,stylePropertyNameCase:t.stylePropertyNameCase||"dom",tableCellAlignToStyle:t.tableCellAlignToStyle!==!1},o=dw(s,e,void 0);return o&&typeof o!="string"?o:s.create(e,s.Fragment,{children:o||void 0},void 0)}function dw(e,t,r){if(t.type==="element")return JA(e,t,r);if(t.type==="mdxFlowExpression"||t.type==="mdxTextExpression")return eM(e,t);if(t.type==="mdxJsxFlowElement"||t.type==="mdxJsxTextElement")return nM(e,t,r);if(t.type==="mdxjsEsm")return tM(e,t);if(t.type==="root")return rM(e,t,r);if(t.type==="text")return iM(e,t)}function JA(e,t,r){const a=e.schema;let s=a;t.tagName.toLowerCase()==="svg"&&a.space==="html"&&(s=Sp,e.schema=s),e.ancestors.push(t);const o=hw(e,t.tagName,!1),c=lM(e,t);let d=Ap(e,t);return ZA.has(t.tagName)&&(d=d.filter(function(h){return typeof h=="string"?!AA(h):!0})),fw(e,c,o,t),Tp(c,d),e.ancestors.pop(),e.schema=a,e.create(t,o,c,r)}function eM(e,t){if(t.data&&t.data.estree&&e.evaluater){const a=t.data.estree.body[0];return a.type,e.evaluater.evaluateExpression(a.expression)}po(e,t.position)}function tM(e,t){if(t.data&&t.data.estree&&e.evaluater)return e.evaluater.evaluateProgram(t.data.estree);po(e,t.position)}function nM(e,t,r){const a=e.schema;let s=a;t.name==="svg"&&a.space==="html"&&(s=Sp,e.schema=s),e.ancestors.push(t);const o=t.name===null?e.Fragment:hw(e,t.name,!0),c=oM(e,t),d=Ap(e,t);return fw(e,c,o,t),Tp(c,d),e.ancestors.pop(),e.schema=a,e.create(t,o,c,r)}function rM(e,t,r){const a={};return Tp(a,Ap(e,t)),e.create(t,e.Fragment,a,r)}function iM(e,t){return t.value}function fw(e,t,r,a){typeof r!="string"&&r!==e.Fragment&&e.passNode&&(t.node=a)}function Tp(e,t){if(t.length>0){const r=t.length>1?t:t[0];r&&(e.children=r)}}function aM(e,t,r){return a;function a(s,o,c,d){const f=Array.isArray(c.children)?r:t;return d?f(o,c,d):f(o,c)}}function sM(e,t){return r;function r(a,s,o,c){const d=Array.isArray(o.children),h=kp(a);return t(s,o,c,d,{columnNumber:h?h.column-1:void 0,fileName:e,lineNumber:h?h.line:void 0},void 0)}}function lM(e,t){const r={};let a,s;for(s in t.properties)if(s!=="children"&&Cp.call(t.properties,s)){const o=cM(e,s,t.properties[s]);if(o){const[c,d]=o;e.tableCellAlignToStyle&&c==="align"&&typeof d=="string"&&QA.has(t.tagName)?a=d:r[c]=d}}if(a){const o=r.style||(r.style={});o[e.stylePropertyNameCase==="css"?"text-align":"textAlign"]=a}return r}function oM(e,t){const r={};for(const a of t.attributes)if(a.type==="mdxJsxExpressionAttribute")if(a.data&&a.data.estree&&e.evaluater){const o=a.data.estree.body[0];o.type;const c=o.expression;c.type;const d=c.properties[0];d.type,Object.assign(r,e.evaluater.evaluateExpression(d.argument))}else po(e,t.position);else{const s=a.name;let o;if(a.value&&typeof a.value=="object")if(a.value.data&&a.value.data.estree&&e.evaluater){const d=a.value.data.estree.body[0];d.type,o=e.evaluater.evaluateExpression(d.expression)}else po(e,t.position);else o=a.value===null?!0:a.value;r[s]=o}return r}function Ap(e,t){const r=[];let a=-1;const s=e.passKeys?new Map:XA;for(;++as?0:s+t:t=t>s?s:t,r=r>0?r:0,a.length<1e4)c=Array.from(a),c.unshift(t,r),e.splice(...c);else for(r&&e.splice(t,r);o0?(ar(e,e.length,0,t),e):t}const wy={}.hasOwnProperty;function pw(e){const t={};let r=-1;for(;++r13&&r<32||r>126&&r<160||r>55295&&r<57344||r>64975&&r<65008||(r&65535)===65535||(r&65535)===65534||r>1114111?"�":String.fromCodePoint(r)}function Dr(e){return e.replace(/[\t\n\r ]+/g," ").replace(/^ | $/g,"").toLowerCase().toUpperCase()}const Ln=ma(/[A-Za-z]/),Tn=ma(/[\dA-Za-z]/),xM=ma(/[#-'*+\--9=?A-Z^-~]/);function _u(e){return e!==null&&(e<32||e===127)}const Vm=ma(/\d/),yM=ma(/[\dA-Fa-f]/),vM=ma(/[!-/:-@[-`{-~]/);function Be(e){return e!==null&&e<-2}function Tt(e){return e!==null&&(e<0||e===32)}function tt(e){return e===-2||e===-1||e===32}const qu=ma(new RegExp("\\p{P}|\\p{S}","u")),Ga=ma(/\s/);function ma(e){return t;function t(r){return r!==null&&r>-1&&e.test(String.fromCharCode(r))}}function nl(e){const t=[];let r=-1,a=0,s=0;for(;++r55295&&o<57344){const d=e.charCodeAt(r+1);o<56320&&d>56319&&d<57344?(c=String.fromCharCode(o,d),s=1):c="�"}else c=String.fromCharCode(o);c&&(t.push(e.slice(a,r),encodeURIComponent(c)),a=r+s+1,c=""),s&&(r+=s,s=0)}return t.join("")+e.slice(a)}function ot(e,t,r,a){const s=a?a-1:Number.POSITIVE_INFINITY;let o=0;return c;function c(h){return tt(h)?(e.enter(r),d(h)):t(h)}function d(h){return tt(h)&&o++c))return;const U=t.events.length;let B=U,Z,j;for(;B--;)if(t.events[B][0]==="exit"&&t.events[B][1].type==="chunkFlow"){if(Z){j=t.events[B][1].end;break}Z=!0}for(w(a),R=U;RE;){const I=r[M];t.containerState=I[1],I[0].exit.call(t,e)}r.length=E}function k(){s.write([null]),o=void 0,s=void 0,t.containerState._closeFlow=void 0}}function SM(e,t,r){return ot(e,e.attempt(this.parser.constructs.document,t,r),"linePrefix",this.parser.constructs.disable.null.includes("codeIndented")?void 0:4)}function Ys(e){if(e===null||Tt(e)||Ga(e))return 1;if(qu(e))return 2}function Pu(e,t,r){const a=[];let s=-1;for(;++s1&&e[r][1].end.offset-e[r][1].start.offset>1?2:1;const p={...e[a][1].end},y={...e[r][1].start};Ny(p,-h),Ny(y,h),c={type:h>1?"strongSequence":"emphasisSequence",start:p,end:{...e[a][1].end}},d={type:h>1?"strongSequence":"emphasisSequence",start:{...e[r][1].start},end:y},o={type:h>1?"strongText":"emphasisText",start:{...e[a][1].end},end:{...e[r][1].start}},s={type:h>1?"strong":"emphasis",start:{...c.start},end:{...d.end}},e[a][1].end={...c.start},e[r][1].start={...d.end},f=[],e[a][1].end.offset-e[a][1].start.offset&&(f=br(f,[["enter",e[a][1],t],["exit",e[a][1],t]])),f=br(f,[["enter",s,t],["enter",c,t],["exit",c,t],["enter",o,t]]),f=br(f,Pu(t.parser.constructs.insideSpan.null,e.slice(a+1,r),t)),f=br(f,[["exit",o,t],["enter",d,t],["exit",d,t],["exit",s,t]]),e[r][1].end.offset-e[r][1].start.offset?(m=2,f=br(f,[["enter",e[r][1],t],["exit",e[r][1],t]])):m=0,ar(e,a-1,r-a+3,f),r=a+f.length-m-2;break}}for(r=-1;++r0&&tt(R)?ot(e,k,"linePrefix",o+1)(R):k(R)}function k(R){return R===null||Be(R)?e.check(Sy,N,M)(R):(e.enter("codeFlowValue"),E(R))}function E(R){return R===null||Be(R)?(e.exit("codeFlowValue"),k(R)):(e.consume(R),E)}function M(R){return e.exit("codeFenced"),t(R)}function I(R,U,B){let Z=0;return j;function j($){return R.enter("lineEnding"),R.consume($),R.exit("lineEnding"),z}function z($){return R.enter("codeFencedFence"),tt($)?ot(R,V,"linePrefix",a.parser.constructs.disable.null.includes("codeIndented")?void 0:4)($):V($)}function V($){return $===d?(R.enter("codeFencedFenceSequence"),P($)):B($)}function P($){return $===d?(Z++,R.consume($),P):Z>=c?(R.exit("codeFencedFenceSequence"),tt($)?ot(R,T,"whitespace")($):T($)):B($)}function T($){return $===null||Be($)?(R.exit("codeFencedFence"),U($)):B($)}}}function IM(e,t,r){const a=this;return s;function s(c){return c===null?r(c):(e.enter("lineEnding"),e.consume(c),e.exit("lineEnding"),o)}function o(c){return a.parser.lazy[a.now().line]?r(c):t(c)}}const xh={name:"codeIndented",tokenize:UM},BM={partial:!0,tokenize:HM};function UM(e,t,r){const a=this;return s;function s(f){return e.enter("codeIndented"),ot(e,o,"linePrefix",5)(f)}function o(f){const m=a.events[a.events.length-1];return m&&m[1].type==="linePrefix"&&m[2].sliceSerialize(m[1],!0).length>=4?c(f):r(f)}function c(f){return f===null?h(f):Be(f)?e.attempt(BM,c,h)(f):(e.enter("codeFlowValue"),d(f))}function d(f){return f===null||Be(f)?(e.exit("codeFlowValue"),c(f)):(e.consume(f),d)}function h(f){return e.exit("codeIndented"),t(f)}}function HM(e,t,r){const a=this;return s;function s(c){return a.parser.lazy[a.now().line]?r(c):Be(c)?(e.enter("lineEnding"),e.consume(c),e.exit("lineEnding"),s):ot(e,o,"linePrefix",5)(c)}function o(c){const d=a.events[a.events.length-1];return d&&d[1].type==="linePrefix"&&d[2].sliceSerialize(d[1],!0).length>=4?t(c):Be(c)?s(c):r(c)}}const $M={name:"codeText",previous:PM,resolve:qM,tokenize:FM};function qM(e){let t=e.length-4,r=3,a,s;if((e[r][1].type==="lineEnding"||e[r][1].type==="space")&&(e[t][1].type==="lineEnding"||e[t][1].type==="space")){for(a=r;++a=this.left.length+this.right.length)throw new RangeError("Cannot access index `"+t+"` in a splice buffer of size `"+(this.left.length+this.right.length)+"`");return tthis.left.length?this.right.slice(this.right.length-a+this.left.length,this.right.length-t+this.left.length).reverse():this.left.slice(t).concat(this.right.slice(this.right.length-a+this.left.length).reverse())}splice(t,r,a){const s=r||0;this.setCursor(Math.trunc(t));const o=this.right.splice(this.right.length-s,Number.POSITIVE_INFINITY);return a&&Wl(this.left,a),o.reverse()}pop(){return this.setCursor(Number.POSITIVE_INFINITY),this.left.pop()}push(t){this.setCursor(Number.POSITIVE_INFINITY),this.left.push(t)}pushMany(t){this.setCursor(Number.POSITIVE_INFINITY),Wl(this.left,t)}unshift(t){this.setCursor(0),this.right.push(t)}unshiftMany(t){this.setCursor(0),Wl(this.right,t.reverse())}setCursor(t){if(!(t===this.left.length||t>this.left.length&&this.right.length===0||t<0&&this.left.length===0))if(t=4?t(c):e.interrupt(a.parser.constructs.flow,r,t)(c)}}function _w(e,t,r,a,s,o,c,d,h){const f=h||Number.POSITIVE_INFINITY;let m=0;return p;function p(w){return w===60?(e.enter(a),e.enter(s),e.enter(o),e.consume(w),e.exit(o),y):w===null||w===32||w===41||_u(w)?r(w):(e.enter(a),e.enter(c),e.enter(d),e.enter("chunkString",{contentType:"string"}),N(w))}function y(w){return w===62?(e.enter(o),e.consume(w),e.exit(o),e.exit(s),e.exit(a),t):(e.enter(d),e.enter("chunkString",{contentType:"string"}),x(w))}function x(w){return w===62?(e.exit("chunkString"),e.exit(d),y(w)):w===null||w===60||Be(w)?r(w):(e.consume(w),w===92?_:x)}function _(w){return w===60||w===62||w===92?(e.consume(w),x):x(w)}function N(w){return!m&&(w===null||w===41||Tt(w))?(e.exit("chunkString"),e.exit(d),e.exit(c),e.exit(a),t(w)):m999||x===null||x===91||x===93&&!h||x===94&&!d&&"_hiddenFootnoteSupport"in c.parser.constructs?r(x):x===93?(e.exit(o),e.enter(s),e.consume(x),e.exit(s),e.exit(a),t):Be(x)?(e.enter("lineEnding"),e.consume(x),e.exit("lineEnding"),m):(e.enter("chunkString",{contentType:"string"}),p(x))}function p(x){return x===null||x===91||x===93||Be(x)||d++>999?(e.exit("chunkString"),m(x)):(e.consume(x),h||(h=!tt(x)),x===92?y:p)}function y(x){return x===91||x===92||x===93?(e.consume(x),d++,p):p(x)}}function Ew(e,t,r,a,s,o){let c;return d;function d(y){return y===34||y===39||y===40?(e.enter(a),e.enter(s),e.consume(y),e.exit(s),c=y===40?41:y,h):r(y)}function h(y){return y===c?(e.enter(s),e.consume(y),e.exit(s),e.exit(a),t):(e.enter(o),f(y))}function f(y){return y===c?(e.exit(o),h(c)):y===null?r(y):Be(y)?(e.enter("lineEnding"),e.consume(y),e.exit("lineEnding"),ot(e,f,"linePrefix")):(e.enter("chunkString",{contentType:"string"}),m(y))}function m(y){return y===c||y===null||Be(y)?(e.exit("chunkString"),f(y)):(e.consume(y),y===92?p:m)}function p(y){return y===c||y===92?(e.consume(y),m):m(y)}}function oo(e,t){let r;return a;function a(s){return Be(s)?(e.enter("lineEnding"),e.consume(s),e.exit("lineEnding"),r=!0,a):tt(s)?ot(e,a,r?"linePrefix":"lineSuffix")(s):t(s)}}const WM={name:"definition",tokenize:e5},JM={partial:!0,tokenize:t5};function e5(e,t,r){const a=this;let s;return o;function o(x){return e.enter("definition"),c(x)}function c(x){return ww.call(a,e,d,r,"definitionLabel","definitionLabelMarker","definitionLabelString")(x)}function d(x){return s=Dr(a.sliceSerialize(a.events[a.events.length-1][1]).slice(1,-1)),x===58?(e.enter("definitionMarker"),e.consume(x),e.exit("definitionMarker"),h):r(x)}function h(x){return Tt(x)?oo(e,f)(x):f(x)}function f(x){return _w(e,m,r,"definitionDestination","definitionDestinationLiteral","definitionDestinationLiteralMarker","definitionDestinationRaw","definitionDestinationString")(x)}function m(x){return e.attempt(JM,p,p)(x)}function p(x){return tt(x)?ot(e,y,"whitespace")(x):y(x)}function y(x){return x===null||Be(x)?(e.exit("definition"),a.parser.defined.push(s),t(x)):r(x)}}function t5(e,t,r){return a;function a(d){return Tt(d)?oo(e,s)(d):r(d)}function s(d){return Ew(e,o,r,"definitionTitle","definitionTitleMarker","definitionTitleString")(d)}function o(d){return tt(d)?ot(e,c,"whitespace")(d):c(d)}function c(d){return d===null||Be(d)?t(d):r(d)}}const n5={name:"hardBreakEscape",tokenize:r5};function r5(e,t,r){return a;function a(o){return e.enter("hardBreakEscape"),e.consume(o),s}function s(o){return Be(o)?(e.exit("hardBreakEscape"),t(o)):r(o)}}const i5={name:"headingAtx",resolve:a5,tokenize:s5};function a5(e,t){let r=e.length-2,a=3,s,o;return e[a][1].type==="whitespace"&&(a+=2),r-2>a&&e[r][1].type==="whitespace"&&(r-=2),e[r][1].type==="atxHeadingSequence"&&(a===r-1||r-4>a&&e[r-2][1].type==="whitespace")&&(r-=a+1===r?2:4),r>a&&(s={type:"atxHeadingText",start:e[a][1].start,end:e[r][1].end},o={type:"chunkText",start:e[a][1].start,end:e[r][1].end,contentType:"text"},ar(e,a,r-a+1,[["enter",s,t],["enter",o,t],["exit",o,t],["exit",s,t]])),e}function s5(e,t,r){let a=0;return s;function s(m){return e.enter("atxHeading"),o(m)}function o(m){return e.enter("atxHeadingSequence"),c(m)}function c(m){return m===35&&a++<6?(e.consume(m),c):m===null||Tt(m)?(e.exit("atxHeadingSequence"),d(m)):r(m)}function d(m){return m===35?(e.enter("atxHeadingSequence"),h(m)):m===null||Be(m)?(e.exit("atxHeading"),t(m)):tt(m)?ot(e,d,"whitespace")(m):(e.enter("atxHeadingText"),f(m))}function h(m){return m===35?(e.consume(m),h):(e.exit("atxHeadingSequence"),d(m))}function f(m){return m===null||m===35||Tt(m)?(e.exit("atxHeadingText"),d(m)):(e.consume(m),f)}}const l5=["address","article","aside","base","basefont","blockquote","body","caption","center","col","colgroup","dd","details","dialog","dir","div","dl","dt","fieldset","figcaption","figure","footer","form","frame","frameset","h1","h2","h3","h4","h5","h6","head","header","hr","html","iframe","legend","li","link","main","menu","menuitem","nav","noframes","ol","optgroup","option","p","param","search","section","summary","table","tbody","td","tfoot","th","thead","title","tr","track","ul"],Cy=["pre","script","style","textarea"],o5={concrete:!0,name:"htmlFlow",resolveTo:d5,tokenize:f5},c5={partial:!0,tokenize:m5},u5={partial:!0,tokenize:h5};function d5(e){let t=e.length;for(;t--&&!(e[t][0]==="enter"&&e[t][1].type==="htmlFlow"););return t>1&&e[t-2][1].type==="linePrefix"&&(e[t][1].start=e[t-2][1].start,e[t+1][1].start=e[t-2][1].start,e.splice(t-2,2)),e}function f5(e,t,r){const a=this;let s,o,c,d,h;return f;function f(L){return m(L)}function m(L){return e.enter("htmlFlow"),e.enter("htmlFlowData"),e.consume(L),p}function p(L){return L===33?(e.consume(L),y):L===47?(e.consume(L),o=!0,N):L===63?(e.consume(L),s=3,a.interrupt?t:C):Ln(L)?(e.consume(L),c=String.fromCharCode(L),S):r(L)}function y(L){return L===45?(e.consume(L),s=2,x):L===91?(e.consume(L),s=5,d=0,_):Ln(L)?(e.consume(L),s=4,a.interrupt?t:C):r(L)}function x(L){return L===45?(e.consume(L),a.interrupt?t:C):r(L)}function _(L){const G="CDATA[";return L===G.charCodeAt(d++)?(e.consume(L),d===G.length?a.interrupt?t:V:_):r(L)}function N(L){return Ln(L)?(e.consume(L),c=String.fromCharCode(L),S):r(L)}function S(L){if(L===null||L===47||L===62||Tt(L)){const G=L===47,q=c.toLowerCase();return!G&&!o&&Cy.includes(q)?(s=1,a.interrupt?t(L):V(L)):l5.includes(c.toLowerCase())?(s=6,G?(e.consume(L),w):a.interrupt?t(L):V(L)):(s=7,a.interrupt&&!a.parser.lazy[a.now().line]?r(L):o?k(L):E(L))}return L===45||Tn(L)?(e.consume(L),c+=String.fromCharCode(L),S):r(L)}function w(L){return L===62?(e.consume(L),a.interrupt?t:V):r(L)}function k(L){return tt(L)?(e.consume(L),k):j(L)}function E(L){return L===47?(e.consume(L),j):L===58||L===95||Ln(L)?(e.consume(L),M):tt(L)?(e.consume(L),E):j(L)}function M(L){return L===45||L===46||L===58||L===95||Tn(L)?(e.consume(L),M):I(L)}function I(L){return L===61?(e.consume(L),R):tt(L)?(e.consume(L),I):E(L)}function R(L){return L===null||L===60||L===61||L===62||L===96?r(L):L===34||L===39?(e.consume(L),h=L,U):tt(L)?(e.consume(L),R):B(L)}function U(L){return L===h?(e.consume(L),h=null,Z):L===null||Be(L)?r(L):(e.consume(L),U)}function B(L){return L===null||L===34||L===39||L===47||L===60||L===61||L===62||L===96||Tt(L)?I(L):(e.consume(L),B)}function Z(L){return L===47||L===62||tt(L)?E(L):r(L)}function j(L){return L===62?(e.consume(L),z):r(L)}function z(L){return L===null||Be(L)?V(L):tt(L)?(e.consume(L),z):r(L)}function V(L){return L===45&&s===2?(e.consume(L),O):L===60&&s===1?(e.consume(L),H):L===62&&s===4?(e.consume(L),D):L===63&&s===3?(e.consume(L),C):L===93&&s===5?(e.consume(L),K):Be(L)&&(s===6||s===7)?(e.exit("htmlFlowData"),e.check(c5,Y,P)(L)):L===null||Be(L)?(e.exit("htmlFlowData"),P(L)):(e.consume(L),V)}function P(L){return e.check(u5,T,Y)(L)}function T(L){return e.enter("lineEnding"),e.consume(L),e.exit("lineEnding"),$}function $(L){return L===null||Be(L)?P(L):(e.enter("htmlFlowData"),V(L))}function O(L){return L===45?(e.consume(L),C):V(L)}function H(L){return L===47?(e.consume(L),c="",X):V(L)}function X(L){if(L===62){const G=c.toLowerCase();return Cy.includes(G)?(e.consume(L),D):V(L)}return Ln(L)&&c.length<8?(e.consume(L),c+=String.fromCharCode(L),X):V(L)}function K(L){return L===93?(e.consume(L),C):V(L)}function C(L){return L===62?(e.consume(L),D):L===45&&s===2?(e.consume(L),C):V(L)}function D(L){return L===null||Be(L)?(e.exit("htmlFlowData"),Y(L)):(e.consume(L),D)}function Y(L){return e.exit("htmlFlow"),t(L)}}function h5(e,t,r){const a=this;return s;function s(c){return Be(c)?(e.enter("lineEnding"),e.consume(c),e.exit("lineEnding"),o):r(c)}function o(c){return a.parser.lazy[a.now().line]?r(c):t(c)}}function m5(e,t,r){return a;function a(s){return e.enter("lineEnding"),e.consume(s),e.exit("lineEnding"),e.attempt(Oo,t,r)}}const p5={name:"htmlText",tokenize:g5};function g5(e,t,r){const a=this;let s,o,c;return d;function d(C){return e.enter("htmlText"),e.enter("htmlTextData"),e.consume(C),h}function h(C){return C===33?(e.consume(C),f):C===47?(e.consume(C),I):C===63?(e.consume(C),E):Ln(C)?(e.consume(C),B):r(C)}function f(C){return C===45?(e.consume(C),m):C===91?(e.consume(C),o=0,_):Ln(C)?(e.consume(C),k):r(C)}function m(C){return C===45?(e.consume(C),x):r(C)}function p(C){return C===null?r(C):C===45?(e.consume(C),y):Be(C)?(c=p,H(C)):(e.consume(C),p)}function y(C){return C===45?(e.consume(C),x):p(C)}function x(C){return C===62?O(C):C===45?y(C):p(C)}function _(C){const D="CDATA[";return C===D.charCodeAt(o++)?(e.consume(C),o===D.length?N:_):r(C)}function N(C){return C===null?r(C):C===93?(e.consume(C),S):Be(C)?(c=N,H(C)):(e.consume(C),N)}function S(C){return C===93?(e.consume(C),w):N(C)}function w(C){return C===62?O(C):C===93?(e.consume(C),w):N(C)}function k(C){return C===null||C===62?O(C):Be(C)?(c=k,H(C)):(e.consume(C),k)}function E(C){return C===null?r(C):C===63?(e.consume(C),M):Be(C)?(c=E,H(C)):(e.consume(C),E)}function M(C){return C===62?O(C):E(C)}function I(C){return Ln(C)?(e.consume(C),R):r(C)}function R(C){return C===45||Tn(C)?(e.consume(C),R):U(C)}function U(C){return Be(C)?(c=U,H(C)):tt(C)?(e.consume(C),U):O(C)}function B(C){return C===45||Tn(C)?(e.consume(C),B):C===47||C===62||Tt(C)?Z(C):r(C)}function Z(C){return C===47?(e.consume(C),O):C===58||C===95||Ln(C)?(e.consume(C),j):Be(C)?(c=Z,H(C)):tt(C)?(e.consume(C),Z):O(C)}function j(C){return C===45||C===46||C===58||C===95||Tn(C)?(e.consume(C),j):z(C)}function z(C){return C===61?(e.consume(C),V):Be(C)?(c=z,H(C)):tt(C)?(e.consume(C),z):Z(C)}function V(C){return C===null||C===60||C===61||C===62||C===96?r(C):C===34||C===39?(e.consume(C),s=C,P):Be(C)?(c=V,H(C)):tt(C)?(e.consume(C),V):(e.consume(C),T)}function P(C){return C===s?(e.consume(C),s=void 0,$):C===null?r(C):Be(C)?(c=P,H(C)):(e.consume(C),P)}function T(C){return C===null||C===34||C===39||C===60||C===61||C===96?r(C):C===47||C===62||Tt(C)?Z(C):(e.consume(C),T)}function $(C){return C===47||C===62||Tt(C)?Z(C):r(C)}function O(C){return C===62?(e.consume(C),e.exit("htmlTextData"),e.exit("htmlText"),t):r(C)}function H(C){return e.exit("htmlTextData"),e.enter("lineEnding"),e.consume(C),e.exit("lineEnding"),X}function X(C){return tt(C)?ot(e,K,"linePrefix",a.parser.constructs.disable.null.includes("codeIndented")?void 0:4)(C):K(C)}function K(C){return e.enter("htmlTextData"),c(C)}}const Rp={name:"labelEnd",resolveAll:v5,resolveTo:_5,tokenize:w5},b5={tokenize:E5},x5={tokenize:N5},y5={tokenize:S5};function v5(e){let t=-1;const r=[];for(;++t=3&&(f===null||Be(f))?(e.exit("thematicBreak"),t(f)):r(f)}function h(f){return f===s?(e.consume(f),a++,h):(e.exit("thematicBreakSequence"),tt(f)?ot(e,d,"whitespace")(f):d(f))}}const Fn={continuation:{tokenize:L5},exit:I5,name:"list",tokenize:D5},R5={partial:!0,tokenize:B5},j5={partial:!0,tokenize:z5};function D5(e,t,r){const a=this,s=a.events[a.events.length-1];let o=s&&s[1].type==="linePrefix"?s[2].sliceSerialize(s[1],!0).length:0,c=0;return d;function d(x){const _=a.containerState.type||(x===42||x===43||x===45?"listUnordered":"listOrdered");if(_==="listUnordered"?!a.containerState.marker||x===a.containerState.marker:Vm(x)){if(a.containerState.type||(a.containerState.type=_,e.enter(_,{_container:!0})),_==="listUnordered")return e.enter("listItemPrefix"),x===42||x===45?e.check(mu,r,f)(x):f(x);if(!a.interrupt||x===49)return e.enter("listItemPrefix"),e.enter("listItemValue"),h(x)}return r(x)}function h(x){return Vm(x)&&++c<10?(e.consume(x),h):(!a.interrupt||c<2)&&(a.containerState.marker?x===a.containerState.marker:x===41||x===46)?(e.exit("listItemValue"),f(x)):r(x)}function f(x){return e.enter("listItemMarker"),e.consume(x),e.exit("listItemMarker"),a.containerState.marker=a.containerState.marker||x,e.check(Oo,a.interrupt?r:m,e.attempt(R5,y,p))}function m(x){return a.containerState.initialBlankLine=!0,o++,y(x)}function p(x){return tt(x)?(e.enter("listItemPrefixWhitespace"),e.consume(x),e.exit("listItemPrefixWhitespace"),y):r(x)}function y(x){return a.containerState.size=o+a.sliceSerialize(e.exit("listItemPrefix"),!0).length,t(x)}}function L5(e,t,r){const a=this;return a.containerState._closeFlow=void 0,e.check(Oo,s,o);function s(d){return a.containerState.furtherBlankLines=a.containerState.furtherBlankLines||a.containerState.initialBlankLine,ot(e,t,"listItemIndent",a.containerState.size+1)(d)}function o(d){return a.containerState.furtherBlankLines||!tt(d)?(a.containerState.furtherBlankLines=void 0,a.containerState.initialBlankLine=void 0,c(d)):(a.containerState.furtherBlankLines=void 0,a.containerState.initialBlankLine=void 0,e.attempt(j5,t,c)(d))}function c(d){return a.containerState._closeFlow=!0,a.interrupt=void 0,ot(e,e.attempt(Fn,t,r),"linePrefix",a.parser.constructs.disable.null.includes("codeIndented")?void 0:4)(d)}}function z5(e,t,r){const a=this;return ot(e,s,"listItemIndent",a.containerState.size+1);function s(o){const c=a.events[a.events.length-1];return c&&c[1].type==="listItemIndent"&&c[2].sliceSerialize(c[1],!0).length===a.containerState.size?t(o):r(o)}}function I5(e){e.exit(this.containerState.type)}function B5(e,t,r){const a=this;return ot(e,s,"listItemPrefixWhitespace",a.parser.constructs.disable.null.includes("codeIndented")?void 0:5);function s(o){const c=a.events[a.events.length-1];return!tt(o)&&c&&c[1].type==="listItemPrefixWhitespace"?t(o):r(o)}}const Ty={name:"setextUnderline",resolveTo:U5,tokenize:H5};function U5(e,t){let r=e.length,a,s,o;for(;r--;)if(e[r][0]==="enter"){if(e[r][1].type==="content"){a=r;break}e[r][1].type==="paragraph"&&(s=r)}else e[r][1].type==="content"&&e.splice(r,1),!o&&e[r][1].type==="definition"&&(o=r);const c={type:"setextHeading",start:{...e[a][1].start},end:{...e[e.length-1][1].end}};return e[s][1].type="setextHeadingText",o?(e.splice(s,0,["enter",c,t]),e.splice(o+1,0,["exit",e[a][1],t]),e[a][1].end={...e[o][1].end}):e[a][1]=c,e.push(["exit",c,t]),e}function H5(e,t,r){const a=this;let s;return o;function o(f){let m=a.events.length,p;for(;m--;)if(a.events[m][1].type!=="lineEnding"&&a.events[m][1].type!=="linePrefix"&&a.events[m][1].type!=="content"){p=a.events[m][1].type==="paragraph";break}return!a.parser.lazy[a.now().line]&&(a.interrupt||p)?(e.enter("setextHeadingLine"),s=f,c(f)):r(f)}function c(f){return e.enter("setextHeadingLineSequence"),d(f)}function d(f){return f===s?(e.consume(f),d):(e.exit("setextHeadingLineSequence"),tt(f)?ot(e,h,"lineSuffix")(f):h(f))}function h(f){return f===null||Be(f)?(e.exit("setextHeadingLine"),t(f)):r(f)}}const $5={tokenize:q5};function q5(e){const t=this,r=e.attempt(Oo,a,e.attempt(this.parser.constructs.flowInitial,s,ot(e,e.attempt(this.parser.constructs.flow,s,e.attempt(YM,s)),"linePrefix")));return r;function a(o){if(o===null){e.consume(o);return}return e.enter("lineEndingBlank"),e.consume(o),e.exit("lineEndingBlank"),t.currentConstruct=void 0,r}function s(o){if(o===null){e.consume(o);return}return e.enter("lineEnding"),e.consume(o),e.exit("lineEnding"),t.currentConstruct=void 0,r}}const P5={resolveAll:Sw()},F5=Nw("string"),G5=Nw("text");function Nw(e){return{resolveAll:Sw(e==="text"?V5:void 0),tokenize:t};function t(r){const a=this,s=this.parser.constructs[e],o=r.attempt(s,c,d);return c;function c(m){return f(m)?o(m):d(m)}function d(m){if(m===null){r.consume(m);return}return r.enter("data"),r.consume(m),h}function h(m){return f(m)?(r.exit("data"),o(m)):(r.consume(m),h)}function f(m){if(m===null)return!0;const p=s[m];let y=-1;if(p)for(;++y-1){const d=c[0];typeof d=="string"?c[0]=d.slice(a):c.shift()}o>0&&c.push(e[s].slice(0,o))}return c}function aO(e,t){let r=-1;const a=[];let s;for(;++r
+ {imgSrc && ( + {path + )} {error &&
{error}
} ); diff --git a/strix/interface/viewer/static/assets/index-C3kQ5kk8.css b/strix/interface/viewer/static/assets/index-C3kQ5kk8.css deleted file mode 100644 index 1d9e3318..00000000 --- a/strix/interface/viewer/static/assets/index-C3kQ5kk8.css +++ /dev/null @@ -1,10 +0,0 @@ -pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*! - Theme: GitHub Dark - Description: Dark theme as seen on github.com - Author: github.com - Maintainer: @Hirse - Updated: 2021-05-15 - - Outdated base version: https://github.com/primer/github-syntax-dark - Current colors taken from GitHub's CSS -*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id{color:#79c0ff}.hljs-regexp,.hljs-string,.hljs-meta .hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-comment,.hljs-code,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}.react-flow{direction:ltr;--xy-edge-stroke-default: #b1b1b7;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #555;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(255, 255, 255, .5);--xy-minimap-background-color-default: #fff;--xy-minimap-mask-background-color-default: rgba(240, 240, 240, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #e2e2e2;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: transparent;--xy-background-pattern-dots-color-default: #91919a;--xy-background-pattern-lines-color-default: #eee;--xy-background-pattern-cross-color-default: #e2e2e2;background-color:var(--xy-background-color, var(--xy-background-color-default));--xy-node-color-default: inherit;--xy-node-border-default: 1px solid #1a192b;--xy-node-background-color-default: #fff;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(0, 0, 0, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #1a192b;--xy-node-border-radius-default: 3px;--xy-handle-background-color-default: #1a192b;--xy-handle-border-color-default: #fff;--xy-selection-background-color-default: rgba(0, 89, 220, .08);--xy-selection-border-default: 1px dotted rgba(0, 89, 220, .8);--xy-controls-button-background-color-default: #fefefe;--xy-controls-button-background-color-hover-default: #f4f4f4;--xy-controls-button-color-default: inherit;--xy-controls-button-color-hover-default: inherit;--xy-controls-button-border-color-default: #eee;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #ffffff;--xy-edge-label-color-default: inherit;--xy-resize-background-color-default: #3367d9}.react-flow.dark{--xy-edge-stroke-default: #3e3e3e;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #727272;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(150, 150, 150, .25);--xy-minimap-background-color-default: #141414;--xy-minimap-mask-background-color-default: rgba(60, 60, 60, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #2b2b2b;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: #141414;--xy-background-pattern-dots-color-default: #555;--xy-background-pattern-lines-color-default: #333;--xy-background-pattern-cross-color-default: #333;--xy-node-color-default: #f8f8f8;--xy-node-border-default: 1px solid #3c3c3c;--xy-node-background-color-default: #1e1e1e;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(255, 255, 255, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #999;--xy-handle-background-color-default: #bebebe;--xy-handle-border-color-default: #1e1e1e;--xy-selection-background-color-default: rgba(200, 200, 220, .08);--xy-selection-border-default: 1px dotted rgba(200, 200, 220, .8);--xy-controls-button-background-color-default: #2b2b2b;--xy-controls-button-background-color-hover-default: #3e3e3e;--xy-controls-button-color-default: #f8f8f8;--xy-controls-button-color-hover-default: #fff;--xy-controls-button-border-color-default: #5b5b5b;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #141414;--xy-edge-label-color-default: #f8f8f8}.react-flow__background{background-color:var(--xy-background-color-props, var(--xy-background-color, var(--xy-background-color-default)));pointer-events:none;z-index:-1}.react-flow__container{position:absolute;width:100%;height:100%;top:0;left:0}.react-flow__pane{z-index:1;touch-action:none}.react-flow__pane.draggable{cursor:grab}.react-flow__pane.dragging{cursor:grabbing}.react-flow__pane.selection{cursor:pointer}.react-flow__viewport{transform-origin:0 0;z-index:2;pointer-events:none}.react-flow__renderer{z-index:4}.react-flow__selection{z-index:6}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible{outline:none}.react-flow__edge-path{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default));stroke-width:var(--xy-edge-stroke-width, var(--xy-edge-stroke-width-default));fill:none}.react-flow__connection-path{stroke:var(--xy-connectionline-stroke, var(--xy-connectionline-stroke-default));stroke-width:var(--xy-connectionline-stroke-width, var(--xy-connectionline-stroke-width-default));fill:none}.react-flow .react-flow__edges{position:absolute}.react-flow .react-flow__edges svg{overflow:visible;position:absolute;pointer-events:none}.react-flow__edge{pointer-events:visibleStroke}.react-flow__edge.selectable{cursor:pointer}.react-flow__edge.animated path{stroke-dasharray:5;animation:dashdraw .5s linear infinite}.react-flow__edge.animated path.react-flow__edge-interaction{stroke-dasharray:none;animation:none}.react-flow__edge.inactive{pointer-events:none}.react-flow__edge.selected,.react-flow__edge:focus,.react-flow__edge:focus-visible{outline:none}.react-flow__edge.selected .react-flow__edge-path,.react-flow__edge.selectable:focus .react-flow__edge-path,.react-flow__edge.selectable:focus-visible .react-flow__edge-path{stroke:var(--xy-edge-stroke-selected, var(--xy-edge-stroke-selected-default))}.react-flow__edge-textwrapper{pointer-events:all}.react-flow__edge .react-flow__edge-text{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__arrowhead polyline{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__arrowhead polyline.arrowclosed{fill:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__connection{pointer-events:none}.react-flow__connection .animated{stroke-dasharray:5;animation:dashdraw .5s linear infinite}svg.react-flow__connectionline{z-index:1001;overflow:visible;position:absolute}.react-flow__nodes{pointer-events:none;transform-origin:0 0}.react-flow__node{position:absolute;-webkit-user-select:none;-moz-user-select:none;user-select:none;pointer-events:all;transform-origin:0 0;box-sizing:border-box;cursor:default}.react-flow__node.selectable{cursor:pointer}.react-flow__node.draggable{cursor:grab;pointer-events:all}.react-flow__node.draggable.dragging{cursor:grabbing}.react-flow__nodesselection{z-index:3;transform-origin:left top;pointer-events:none}.react-flow__nodesselection-rect{position:absolute;pointer-events:all;cursor:grab}.react-flow__handle{position:absolute;pointer-events:none;min-width:5px;min-height:5px;width:6px;height:6px;background-color:var(--xy-handle-background-color, var(--xy-handle-background-color-default));border:1px solid var(--xy-handle-border-color, var(--xy-handle-border-color-default));border-radius:100%}.react-flow__handle.connectingfrom{pointer-events:all}.react-flow__handle.connectionindicator{pointer-events:all;cursor:crosshair}.react-flow__handle-bottom{top:auto;left:50%;bottom:0;transform:translate(-50%,50%)}.react-flow__handle-top{top:0;left:50%;transform:translate(-50%,-50%)}.react-flow__handle-left{top:50%;left:0;transform:translate(-50%,-50%)}.react-flow__handle-right{top:50%;right:0;transform:translate(50%,-50%)}.react-flow__edgeupdater{cursor:move;pointer-events:all}.react-flow__pane.selection .react-flow__panel{pointer-events:none}.react-flow__panel{position:absolute;z-index:5;margin:15px}.react-flow__panel.top{top:0}.react-flow__panel.bottom{bottom:0}.react-flow__panel.top.center,.react-flow__panel.bottom.center{left:50%;transform:translate(-15px) translate(-50%)}.react-flow__panel.left{left:0}.react-flow__panel.right{right:0}.react-flow__panel.left.center,.react-flow__panel.right.center{top:50%;transform:translateY(-15px) translateY(-50%)}.react-flow__attribution{font-size:10px;background:var(--xy-attribution-background-color, var(--xy-attribution-background-color-default));padding:2px 3px;margin:0}.react-flow__attribution a{text-decoration:none;color:#999}@keyframes dashdraw{0%{stroke-dashoffset:10}}.react-flow__edgelabel-renderer{position:absolute;width:100%;height:100%;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;left:0;top:0}.react-flow__viewport-portal{position:absolute;width:100%;height:100%;left:0;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__minimap{background:var( --xy-minimap-background-color-props, var(--xy-minimap-background-color, var(--xy-minimap-background-color-default)) )}.react-flow__minimap-svg{display:block}.react-flow__minimap-mask{fill:var( --xy-minimap-mask-background-color-props, var(--xy-minimap-mask-background-color, var(--xy-minimap-mask-background-color-default)) );stroke:var( --xy-minimap-mask-stroke-color-props, var(--xy-minimap-mask-stroke-color, var(--xy-minimap-mask-stroke-color-default)) );stroke-width:var( --xy-minimap-mask-stroke-width-props, var(--xy-minimap-mask-stroke-width, var(--xy-minimap-mask-stroke-width-default)) )}.react-flow__minimap-node{fill:var( --xy-minimap-node-background-color-props, var(--xy-minimap-node-background-color, var(--xy-minimap-node-background-color-default)) );stroke:var( --xy-minimap-node-stroke-color-props, var(--xy-minimap-node-stroke-color, var(--xy-minimap-node-stroke-color-default)) );stroke-width:var( --xy-minimap-node-stroke-width-props, var(--xy-minimap-node-stroke-width, var(--xy-minimap-node-stroke-width-default)) )}.react-flow__background-pattern.dots{fill:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-dots-color-default)) )}.react-flow__background-pattern.lines{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-lines-color-default)) )}.react-flow__background-pattern.cross{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-cross-color-default)) )}.react-flow__controls{display:flex;flex-direction:column;box-shadow:var(--xy-controls-box-shadow, var(--xy-controls-box-shadow-default))}.react-flow__controls.horizontal{flex-direction:row}.react-flow__controls-button{display:flex;justify-content:center;align-items:center;height:26px;width:26px;padding:4px;border:none;background:var(--xy-controls-button-background-color, var(--xy-controls-button-background-color-default));border-bottom:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) );color:var( --xy-controls-button-color-props, var(--xy-controls-button-color, var(--xy-controls-button-color-default)) );cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__controls-button svg{width:100%;max-width:12px;max-height:12px;fill:currentColor}.react-flow__edge.updating .react-flow__edge-path{stroke:#777}.react-flow__edge-text{font-size:10px}.react-flow__node.selectable:focus,.react-flow__node.selectable:focus-visible{outline:none}.react-flow__node-input,.react-flow__node-default,.react-flow__node-output,.react-flow__node-group{padding:10px;border-radius:var(--xy-node-border-radius, var(--xy-node-border-radius-default));width:150px;font-size:12px;color:var(--xy-node-color, var(--xy-node-color-default));text-align:center;border:var(--xy-node-border, var(--xy-node-border-default));background-color:var(--xy-node-background-color, var(--xy-node-background-color-default))}.react-flow__node-input.selectable:hover,.react-flow__node-default.selectable:hover,.react-flow__node-output.selectable:hover,.react-flow__node-group.selectable:hover{box-shadow:var(--xy-node-boxshadow-hover, var(--xy-node-boxshadow-hover-default))}.react-flow__node-input.selectable.selected,.react-flow__node-input.selectable:focus,.react-flow__node-input.selectable:focus-visible,.react-flow__node-default.selectable.selected,.react-flow__node-default.selectable:focus,.react-flow__node-default.selectable:focus-visible,.react-flow__node-output.selectable.selected,.react-flow__node-output.selectable:focus,.react-flow__node-output.selectable:focus-visible,.react-flow__node-group.selectable.selected,.react-flow__node-group.selectable:focus,.react-flow__node-group.selectable:focus-visible{box-shadow:var(--xy-node-boxshadow-selected, var(--xy-node-boxshadow-selected-default))}.react-flow__node-group{background-color:var(--xy-node-group-background-color, var(--xy-node-group-background-color-default))}.react-flow__nodesselection-rect,.react-flow__selection{background:var(--xy-selection-background-color, var(--xy-selection-background-color-default));border:var(--xy-selection-border, var(--xy-selection-border-default))}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible,.react-flow__selection:focus,.react-flow__selection:focus-visible{outline:none}.react-flow__controls-button:hover{background:var( --xy-controls-button-background-color-hover-props, var(--xy-controls-button-background-color-hover, var(--xy-controls-button-background-color-hover-default)) );color:var( --xy-controls-button-color-hover-props, var(--xy-controls-button-color-hover, var(--xy-controls-button-color-hover-default)) )}.react-flow__controls-button:disabled{pointer-events:none}.react-flow__controls-button:disabled svg{fill-opacity:.4}.react-flow__controls-button:last-child{border-bottom:none}.react-flow__controls.horizontal .react-flow__controls-button{border-bottom:none;border-right:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) )}.react-flow__controls.horizontal .react-flow__controls-button:last-child{border-right:none}.react-flow__resize-control{position:absolute}.react-flow__resize-control.left,.react-flow__resize-control.right{cursor:ew-resize}.react-flow__resize-control.top,.react-flow__resize-control.bottom{cursor:ns-resize}.react-flow__resize-control.top.left,.react-flow__resize-control.bottom.right{cursor:nwse-resize}.react-flow__resize-control.bottom.left,.react-flow__resize-control.top.right{cursor:nesw-resize}.react-flow__resize-control.handle{width:5px;height:5px;border:1px solid #fff;border-radius:1px;background-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));translate:-50% -50%}.react-flow__resize-control.handle.left{left:0;top:50%}.react-flow__resize-control.handle.right{left:100%;top:50%}.react-flow__resize-control.handle.top{left:50%;top:0}.react-flow__resize-control.handle.bottom{left:50%;top:100%}.react-flow__resize-control.handle.top.left,.react-flow__resize-control.handle.bottom.left{left:0}.react-flow__resize-control.handle.top.right,.react-flow__resize-control.handle.bottom.right{left:100%}.react-flow__resize-control.line{border-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));border-width:0;border-style:solid}.react-flow__resize-control.line.left,.react-flow__resize-control.line.right{width:1px;transform:translate(-50%);top:0;height:100%}.react-flow__resize-control.line.left{left:0;border-left-width:1px}.react-flow__resize-control.line.right{left:100%;border-right-width:1px}.react-flow__resize-control.line.top,.react-flow__resize-control.line.bottom{height:1px;transform:translateY(-50%);left:0;width:100%}.react-flow__resize-control.line.top{top:0;border-top-width:1px}.react-flow__resize-control.line.bottom{border-bottom-width:1px;top:100%}.react-flow__edge-textbg{fill:var(--xy-edge-label-background-color, var(--xy-edge-label-background-color-default))}.react-flow__edge-text{fill:var(--xy-edge-label-color, var(--xy-edge-label-color-default))}/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-amber-400:oklch(82.8% .189 84.429);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-cyan-400:oklch(78.9% .154 211.53);--color-cyan-500:oklch(71.5% .143 215.221);--color-sky-400:oklch(74.6% .16 232.661);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-neutral-200:oklch(92.2% 0 none);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-xl:36rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-snug:1.375;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-geist-sans);--default-mono-font-family:var(--font-geist-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{top:0;right:0;bottom:0;left:0}.inset-x-0{inset-inline:0}.top-0{top:0}.top-2{top:calc(var(--spacing) * 2)}.top-4{top:calc(var(--spacing) * 4)}.right-0{right:0}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.bottom-0{bottom:0}.bottom-3{bottom:calc(var(--spacing) * 3)}.bottom-full{bottom:100%}.left-0{left:0}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-3{left:calc(var(--spacing) * 3)}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.-mx-2{margin-inline:calc(var(--spacing) * -2)}.mx-0{margin-inline:0}.mx-auto{margin-inline:auto}.my-1{margin-block:var(--spacing)}.my-4{margin-block:calc(var(--spacing) * 4)}.-mt-0\.5{margin-top:calc(var(--spacing) * -.5)}.mt-0{margin-top:0}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-\[1px\]{margin-top:1px}.-mr-0\.5{margin-right:calc(var(--spacing) * -.5)}.mr-1{margin-right:var(--spacing)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-2\.5{margin-bottom:calc(var(--spacing) * 2.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-auto{margin-left:auto}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.\!h-1\.5{height:calc(var(--spacing) * 1.5)!important}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-\[30px\]{height:30px}.h-\[60vh\]{height:60vh}.h-\[72px\]{height:72px}.h-\[480px\]{height:480px}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[160px\]{max-height:160px}.max-h-\[400px\]{max-height:400px}.max-h-\[1200px\]{max-height:1200px}.min-h-screen{min-height:100vh}.\!w-1\.5{width:calc(var(--spacing) * 1.5)!important}.w-0\.5{width:calc(var(--spacing) * .5)}.w-1{width:var(--spacing)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-28{width:calc(var(--spacing) * 28)}.w-96{width:calc(var(--spacing) * 96)}.w-\[1px\]{width:1px}.w-\[30px\]{width:30px}.w-\[180px\]{width:180px}.w-\[260px\]{width:260px}.w-\[calc\(100vw-4rem\)\]{width:calc(100vw - 4rem)}.w-full{width:100%}.w-max{width:max-content}.w-px{width:1px}.max-w-6xl{max-width:var(--container-6xl)}.max-w-\[75\%\]{max-width:75%}.max-w-\[80\%\]{max-width:80%}.max-w-\[88rem\]{max-width:88rem}.max-w-\[140px\]{max-width:140px}.max-w-\[240px\]{max-width:240px}.max-w-\[260px\]{max-width:260px}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-\[80px\]{min-width:80px}.min-w-\[112px\]{min-width:112px}.min-w-\[160px\]{min-width:160px}.flex-1{flex:1}.flex-none{flex:none}.flex-shrink-0,.shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}.origin-left{transform-origin:0}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.-rotate-90{rotate:-90deg}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-pointer{cursor:pointer}.resize{resize:both}.resize-none{resize:none}.resize-y{resize:vertical}.scrollbar-thin{scrollbar-width:thin}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-\[7rem_1fr\]{grid-template-columns:7rem 1fr}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.place-content-center{place-content:center}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-10{gap:calc(var(--spacing) * 10)}.gap-px{gap:1px}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-1{row-gap:var(--spacing)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}.gap-y-6{row-gap:calc(var(--spacing) * 6)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-clip{overflow-x:clip}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.\!border-none{--tw-border-style:none!important;border-style:none!important}.\!border-\[\#222\]{border-color:#222!important}.border-\[\#1a1a1a\]{border-color:#1a1a1a}.border-\[\#2a2a2a\]{border-color:#2a2a2a}.border-\[\#3a3a3a\]{border-color:#3a3a3a}.border-\[\#22c55e\]\/40{border-color:#22c55e66}.border-\[\#222\]{border-color:#222}.border-\[\#333\]{border-color:#333}.border-\[\#444\]{border-color:#444}.border-\[\#191919\]{border-color:#191919}.border-\[rgba\(255\,255\,255\,0\.08\)\]{border-color:#ffffff14}.border-blue-500\/20{border-color:#3080ff33}@supports (color:color-mix(in lab,red,red)){.border-blue-500\/20{border-color:color-mix(in oklab,var(--color-blue-500) 20%,transparent)}}.border-blue-500\/30{border-color:#3080ff4d}@supports (color:color-mix(in lab,red,red)){.border-blue-500\/30{border-color:color-mix(in oklab,var(--color-blue-500) 30%,transparent)}}.border-blue-500\/40{border-color:#3080ff66}@supports (color:color-mix(in lab,red,red)){.border-blue-500\/40{border-color:color-mix(in oklab,var(--color-blue-500) 40%,transparent)}}.border-emerald-500\/20{border-color:#00bb7f33}@supports (color:color-mix(in lab,red,red)){.border-emerald-500\/20{border-color:color-mix(in oklab,var(--color-emerald-500) 20%,transparent)}}.border-emerald-500\/25{border-color:#00bb7f40}@supports (color:color-mix(in lab,red,red)){.border-emerald-500\/25{border-color:color-mix(in oklab,var(--color-emerald-500) 25%,transparent)}}.border-emerald-500\/30{border-color:#00bb7f4d}@supports (color:color-mix(in lab,red,red)){.border-emerald-500\/30{border-color:color-mix(in oklab,var(--color-emerald-500) 30%,transparent)}}.border-gray-500\/20{border-color:#6a728233}@supports (color:color-mix(in lab,red,red)){.border-gray-500\/20{border-color:color-mix(in oklab,var(--color-gray-500) 20%,transparent)}}.border-orange-500\/20{border-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.border-orange-500\/20{border-color:color-mix(in oklab,var(--color-orange-500) 20%,transparent)}}.border-orange-500\/30{border-color:#fe6e004d}@supports (color:color-mix(in lab,red,red)){.border-orange-500\/30{border-color:color-mix(in oklab,var(--color-orange-500) 30%,transparent)}}.border-purple-500\/20{border-color:#ac4bff33}@supports (color:color-mix(in lab,red,red)){.border-purple-500\/20{border-color:color-mix(in oklab,var(--color-purple-500) 20%,transparent)}}.border-red-500\/20{border-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.border-red-500\/20{border-color:color-mix(in oklab,var(--color-red-500) 20%,transparent)}}.border-red-500\/25{border-color:#fb2c3640}@supports (color:color-mix(in lab,red,red)){.border-red-500\/25{border-color:color-mix(in oklab,var(--color-red-500) 25%,transparent)}}.border-red-500\/30{border-color:#fb2c364d}@supports (color:color-mix(in lab,red,red)){.border-red-500\/30{border-color:color-mix(in oklab,var(--color-red-500) 30%,transparent)}}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab,red,red)){.border-white\/10{border-color:color-mix(in oklab,var(--color-white) 10%,transparent)}}.border-white\/30{border-color:#ffffff4d}@supports (color:color-mix(in lab,red,red)){.border-white\/30{border-color:color-mix(in oklab,var(--color-white) 30%,transparent)}}.border-white\/\[0\.06\]{border-color:#ffffff0f}@supports (color:color-mix(in lab,red,red)){.border-white\/\[0\.06\]{border-color:color-mix(in oklab,var(--color-white) 6%,transparent)}}.border-white\/\[0\.08\]{border-color:#ffffff14}@supports (color:color-mix(in lab,red,red)){.border-white\/\[0\.08\]{border-color:color-mix(in oklab,var(--color-white) 8%,transparent)}}.border-white\/\[0\.18\]{border-color:#ffffff2e}@supports (color:color-mix(in lab,red,red)){.border-white\/\[0\.18\]{border-color:color-mix(in oklab,var(--color-white) 18%,transparent)}}.border-yellow-500\/20{border-color:#edb20033}@supports (color:color-mix(in lab,red,red)){.border-yellow-500\/20{border-color:color-mix(in oklab,var(--color-yellow-500) 20%,transparent)}}.border-yellow-500\/25{border-color:#edb20040}@supports (color:color-mix(in lab,red,red)){.border-yellow-500\/25{border-color:color-mix(in oklab,var(--color-yellow-500) 25%,transparent)}}.border-yellow-500\/30{border-color:#edb2004d}@supports (color:color-mix(in lab,red,red)){.border-yellow-500\/30{border-color:color-mix(in oklab,var(--color-yellow-500) 30%,transparent)}}.border-t-white{border-top-color:var(--color-white)}.\!bg-\[\#0a0a0a\]{background-color:#0a0a0a!important}.\!bg-\[\#444\]{background-color:#444!important}.\!bg-transparent{background-color:#0000!important}.bg-\[\#0a0a0a\]{background-color:#0a0a0a}.bg-\[\#1a1a1a\]{background-color:#1a1a1a}.bg-\[\#1e1e1e\]{background-color:#1e1e1e}.bg-\[\#2a2a2a\]{background-color:#2a2a2a}.bg-\[\#22c55e\]\/10{background-color:#22c55e1a}.bg-\[\#111\]{background-color:#111}.bg-\[\#222\]{background-color:#222}.bg-\[\#555\]{background-color:#555}.bg-\[\#888\]{background-color:#888}.bg-\[\#050505\]{background-color:#050505}.bg-\[\#252525\]{background-color:#252525}.bg-\[rgba\(255\,255\,255\,0\.02\)\]{background-color:#ffffff05}.bg-\[rgba\(255\,255\,255\,0\.3\)\]{background-color:#ffffff4d}.bg-\[rgba\(255\,255\,255\,0\.04\)\]{background-color:#ffffff0a}.bg-\[rgba\(255\,255\,255\,0\.05\)\]{background-color:#ffffff0d}.bg-\[rgba\(255\,255\,255\,0\.08\)\]{background-color:#ffffff14}.bg-\[rgba\(255\,255\,255\,0\.12\)\]{background-color:#ffffff1f}.bg-black{background-color:var(--color-black)}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab,red,red)){.bg-black\/80{background-color:color-mix(in oklab,var(--color-black) 80%,transparent)}}.bg-blue-400{background-color:var(--color-blue-400)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\/10{background-color:color-mix(in oklab,var(--color-blue-500) 10%,transparent)}}.bg-blue-500\/20{background-color:#3080ff33}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\/20{background-color:color-mix(in oklab,var(--color-blue-500) 20%,transparent)}}.bg-blue-500\/\[0\.12\]{background-color:#3080ff1f}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\/\[0\.12\]{background-color:color-mix(in oklab,var(--color-blue-500) 12%,transparent)}}.bg-emerald-400{background-color:var(--color-emerald-400)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-500\/5{background-color:#00bb7f0d}@supports (color:color-mix(in lab,red,red)){.bg-emerald-500\/5{background-color:color-mix(in oklab,var(--color-emerald-500) 5%,transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab,red,red)){.bg-emerald-500\/10{background-color:color-mix(in oklab,var(--color-emerald-500) 10%,transparent)}}.bg-emerald-500\/\[0\.06\]{background-color:#00bb7f0f}@supports (color:color-mix(in lab,red,red)){.bg-emerald-500\/\[0\.06\]{background-color:color-mix(in oklab,var(--color-emerald-500) 6%,transparent)}}.bg-gray-500{background-color:var(--color-gray-500)}.bg-gray-500\/10{background-color:#6a72821a}@supports (color:color-mix(in lab,red,red)){.bg-gray-500\/10{background-color:color-mix(in oklab,var(--color-gray-500) 10%,transparent)}}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-500\/10{background-color:#fe6e001a}@supports (color:color-mix(in lab,red,red)){.bg-orange-500\/10{background-color:color-mix(in oklab,var(--color-orange-500) 10%,transparent)}}.bg-orange-500\/20{background-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.bg-orange-500\/20{background-color:color-mix(in oklab,var(--color-orange-500) 20%,transparent)}}.bg-purple-500{background-color:var(--color-purple-500)}.bg-purple-500\/10{background-color:#ac4bff1a}@supports (color:color-mix(in lab,red,red)){.bg-purple-500\/10{background-color:color-mix(in oklab,var(--color-purple-500) 10%,transparent)}}.bg-purple-500\/\[0\.08\]{background-color:#ac4bff14}@supports (color:color-mix(in lab,red,red)){.bg-purple-500\/\[0\.08\]{background-color:color-mix(in oklab,var(--color-purple-500) 8%,transparent)}}.bg-red-400{background-color:var(--color-red-400)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\/5{background-color:#fb2c360d}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/5{background-color:color-mix(in oklab,var(--color-red-500) 5%,transparent)}}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/10{background-color:color-mix(in oklab,var(--color-red-500) 10%,transparent)}}.bg-red-500\/15{background-color:#fb2c3626}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/15{background-color:color-mix(in oklab,var(--color-red-500) 15%,transparent)}}.bg-red-500\/20{background-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/20{background-color:color-mix(in oklab,var(--color-red-500) 20%,transparent)}}.bg-red-500\/\[0\.12\]{background-color:#fb2c361f}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/\[0\.12\]{background-color:color-mix(in oklab,var(--color-red-500) 12%,transparent)}}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/8{background-color:#ffffff14}@supports (color:color-mix(in lab,red,red)){.bg-white\/8{background-color:color-mix(in oklab,var(--color-white) 8%,transparent)}}.bg-white\/60{background-color:#fff9}@supports (color:color-mix(in lab,red,red)){.bg-white\/60{background-color:color-mix(in oklab,var(--color-white) 60%,transparent)}}.bg-white\/\[0\.03\]{background-color:#ffffff08}@supports (color:color-mix(in lab,red,red)){.bg-white\/\[0\.03\]{background-color:color-mix(in oklab,var(--color-white) 3%,transparent)}}.bg-white\/\[0\.08\]{background-color:#ffffff14}@supports (color:color-mix(in lab,red,red)){.bg-white\/\[0\.08\]{background-color:color-mix(in oklab,var(--color-white) 8%,transparent)}}.bg-white\/\[0\.015\]{background-color:#ffffff04}@supports (color:color-mix(in lab,red,red)){.bg-white\/\[0\.015\]{background-color:color-mix(in oklab,var(--color-white) 1.5%,transparent)}}.bg-yellow-400{background-color:var(--color-yellow-400)}.bg-yellow-500{background-color:var(--color-yellow-500)}.bg-yellow-500\/10{background-color:#edb2001a}@supports (color:color-mix(in lab,red,red)){.bg-yellow-500\/10{background-color:color-mix(in oklab,var(--color-yellow-500) 10%,transparent)}}.bg-yellow-500\/15{background-color:#edb20026}@supports (color:color-mix(in lab,red,red)){.bg-yellow-500\/15{background-color:color-mix(in oklab,var(--color-yellow-500) 15%,transparent)}}.bg-yellow-500\/20{background-color:#edb20033}@supports (color:color-mix(in lab,red,red)){.bg-yellow-500\/20{background-color:color-mix(in oklab,var(--color-yellow-500) 20%,transparent)}}.bg-gradient-to-br{--tw-gradient-position:to bottom right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-emerald-400{--tw-gradient-from:var(--color-emerald-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-cyan-500{--tw-gradient-to:var(--color-cyan-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.object-cover{object-fit:cover}.p-0{padding:0}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-3\.5{padding:calc(var(--spacing) * 3.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-10{padding:calc(var(--spacing) * 10)}.px-0{padding-inline:0}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-8{padding-block:calc(var(--spacing) * 8)}.pt-1{padding-top:var(--spacing)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-10{padding-top:calc(var(--spacing) * 10)}.pt-\[5px\]{padding-top:5px}.pr-1{padding-right:var(--spacing)}.pr-1\.5{padding-right:calc(var(--spacing) * 1.5)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pb-2\.5{padding-bottom:calc(var(--spacing) * 2.5)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-10{padding-bottom:calc(var(--spacing) * 10)}.pl-1\.5{padding-left:calc(var(--spacing) * 1.5)}.pl-2\.5{padding-left:calc(var(--spacing) * 2.5)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:var(--font-geist-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[15px\]{font-size:15px}.text-\[16px\]{font-size:16px}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-\[22px\]{--tw-leading:22px;line-height:22px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.4em\]{--tw-tracking:.4em;letter-spacing:.4em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[\#22c55e\]{color:#22c55e}.text-\[\#333\]{color:#333}.text-\[\#444\]{color:#444}.text-\[\#555\]{color:#555}.text-\[\#666\]{color:#666}.text-\[\#777\]{color:#777}.text-\[\#888\]{color:#888}.text-\[\#999\]{color:#999}.text-\[\#aaa\]{color:#aaa}.text-\[\#bbb\]{color:#bbb}.text-\[\#ddd\]{color:#ddd}.text-\[\#e5e5e5\]{color:#e5e5e5}.text-\[\#ededed\]{color:#ededed}.text-amber-400{color:var(--color-amber-400)}.text-amber-400\/80{color:#fcbb00cc}@supports (color:color-mix(in lab,red,red)){.text-amber-400\/80{color:color-mix(in oklab,var(--color-amber-400) 80%,transparent)}}.text-black{color:var(--color-black)}.text-blue-400{color:var(--color-blue-400)}.text-blue-400\/80{color:#54a2ffcc}@supports (color:color-mix(in lab,red,red)){.text-blue-400\/80{color:color-mix(in oklab,var(--color-blue-400) 80%,transparent)}}.text-blue-500{color:var(--color-blue-500)}.text-cyan-400{color:var(--color-cyan-400)}.text-cyan-400\/80{color:#00d2efcc}@supports (color:color-mix(in lab,red,red)){.text-cyan-400\/80{color:color-mix(in oklab,var(--color-cyan-400) 80%,transparent)}}.text-emerald-200{color:var(--color-emerald-200)}.text-emerald-400{color:var(--color-emerald-400)}.text-emerald-400\/30{color:#00d2944d}@supports (color:color-mix(in lab,red,red)){.text-emerald-400\/30{color:color-mix(in oklab,var(--color-emerald-400) 30%,transparent)}}.text-emerald-400\/60{color:#00d29499}@supports (color:color-mix(in lab,red,red)){.text-emerald-400\/60{color:color-mix(in oklab,var(--color-emerald-400) 60%,transparent)}}.text-emerald-400\/70{color:#00d294b3}@supports (color:color-mix(in lab,red,red)){.text-emerald-400\/70{color:color-mix(in oklab,var(--color-emerald-400) 70%,transparent)}}.text-emerald-400\/80{color:#00d294cc}@supports (color:color-mix(in lab,red,red)){.text-emerald-400\/80{color:color-mix(in oklab,var(--color-emerald-400) 80%,transparent)}}.text-gray-400{color:var(--color-gray-400)}.text-orange-400{color:var(--color-orange-400)}.text-orange-400\/60{color:#ff8b1a99}@supports (color:color-mix(in lab,red,red)){.text-orange-400\/60{color:color-mix(in oklab,var(--color-orange-400) 60%,transparent)}}.text-orange-400\/80{color:#ff8b1acc}@supports (color:color-mix(in lab,red,red)){.text-orange-400\/80{color:color-mix(in oklab,var(--color-orange-400) 80%,transparent)}}.text-orange-500{color:var(--color-orange-500)}.text-purple-400{color:var(--color-purple-400)}.text-purple-400\/60{color:#c07eff99}@supports (color:color-mix(in lab,red,red)){.text-purple-400\/60{color:color-mix(in oklab,var(--color-purple-400) 60%,transparent)}}.text-purple-400\/70{color:#c07effb3}@supports (color:color-mix(in lab,red,red)){.text-purple-400\/70{color:color-mix(in oklab,var(--color-purple-400) 70%,transparent)}}.text-purple-400\/80{color:#c07effcc}@supports (color:color-mix(in lab,red,red)){.text-purple-400\/80{color:color-mix(in oklab,var(--color-purple-400) 80%,transparent)}}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.text-red-400\/30{color:#ff65684d}@supports (color:color-mix(in lab,red,red)){.text-red-400\/30{color:color-mix(in oklab,var(--color-red-400) 30%,transparent)}}.text-red-400\/50{color:#ff656880}@supports (color:color-mix(in lab,red,red)){.text-red-400\/50{color:color-mix(in oklab,var(--color-red-400) 50%,transparent)}}.text-red-400\/60{color:#ff656899}@supports (color:color-mix(in lab,red,red)){.text-red-400\/60{color:color-mix(in oklab,var(--color-red-400) 60%,transparent)}}.text-red-400\/70{color:#ff6568b3}@supports (color:color-mix(in lab,red,red)){.text-red-400\/70{color:color-mix(in oklab,var(--color-red-400) 70%,transparent)}}.text-red-400\/80{color:#ff6568cc}@supports (color:color-mix(in lab,red,red)){.text-red-400\/80{color:color-mix(in oklab,var(--color-red-400) 80%,transparent)}}.text-red-500{color:var(--color-red-500)}.text-sky-400{color:var(--color-sky-400)}.text-sky-400\/80{color:#00bcfecc}@supports (color:color-mix(in lab,red,red)){.text-sky-400\/80{color:color-mix(in oklab,var(--color-sky-400) 80%,transparent)}}.text-white{color:var(--color-white)}.text-white\/80{color:#fffc}@supports (color:color-mix(in lab,red,red)){.text-white\/80{color:color-mix(in oklab,var(--color-white) 80%,transparent)}}.text-yellow-400{color:var(--color-yellow-400)}.text-yellow-400\/80{color:#fac800cc}@supports (color:color-mix(in lab,red,red)){.text-yellow-400\/80{color:color-mix(in oklab,var(--color-yellow-400) 80%,transparent)}}.text-yellow-500{color:var(--color-yellow-500)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.line-through{text-decoration-line:line-through}.opacity-0{opacity:0}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-90{opacity:.9}.opacity-100{opacity:1}.\!shadow-none{--tw-shadow:0 0 #0000!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.\[font-variant-ligatures\:none\]{font-variant-ligatures:none}@media(hover:hover){.group-hover\:bg-\[rgba\(255\,255\,255\,0\.2\)\]:is(:where(.group):hover *){background-color:#fff3}.group-hover\:text-\[\#aaa\]:is(:where(.group):hover *){color:#aaa}.group-hover\:text-white:is(:where(.group):hover *){color:var(--color-white)}.group-hover\:opacity-90:is(:where(.group):hover *){opacity:.9}.group-hover\/code\:opacity-100:is(:where(.group\/code):hover *){opacity:1}}.placeholder\:text-\[\#444\]::placeholder{color:#444}@media(hover:hover){.hover\:border-\[\#333\]:hover{border-color:#333}.hover\:border-\[\#444\]:hover{border-color:#444}.hover\:border-\[\#555\]:hover{border-color:#555}.hover\:border-emerald-500\/40:hover{border-color:#00bb7f66}@supports (color:color-mix(in lab,red,red)){.hover\:border-emerald-500\/40:hover{border-color:color-mix(in oklab,var(--color-emerald-500) 40%,transparent)}}.hover\:border-white\/\[0\.12\]:hover{border-color:#ffffff1f}@supports (color:color-mix(in lab,red,red)){.hover\:border-white\/\[0\.12\]:hover{border-color:color-mix(in oklab,var(--color-white) 12%,transparent)}}.hover\:border-white\/\[0\.16\]:hover{border-color:#ffffff29}@supports (color:color-mix(in lab,red,red)){.hover\:border-white\/\[0\.16\]:hover{border-color:color-mix(in oklab,var(--color-white) 16%,transparent)}}.hover\:bg-\[\#1a1a1a\]:hover{background-color:#1a1a1a}.hover\:bg-\[\#2a2a2a\]:hover{background-color:#2a2a2a}.hover\:bg-\[rgba\(255\,255\,255\,0\.06\)\]:hover{background-color:#ffffff0f}.hover\:bg-\[rgba\(255\,255\,255\,0\.08\)\]:hover{background-color:#ffffff14}.hover\:bg-\[rgba\(255\,255\,255\,0\.09\)\]:hover{background-color:#ffffff17}.hover\:bg-neutral-200:hover{background-color:var(--color-neutral-200)}.hover\:bg-white\/\[0\.06\]:hover{background-color:#ffffff0f}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/\[0\.06\]:hover{background-color:color-mix(in oklab,var(--color-white) 6%,transparent)}}.hover\:text-\[\#888\]:hover{color:#888}.hover\:text-\[\#aaa\]:hover{color:#aaa}.hover\:text-\[\#ccc\]:hover{color:#ccc}.hover\:text-\[\#ededed\]:hover{color:#ededed}.hover\:text-red-400:hover{color:var(--color-red-400)}.hover\:text-white:hover{color:var(--color-white)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-90:hover{opacity:.9}.hover\:opacity-100:hover{opacity:1}}.focus\:border-\[\#444\]:focus{border-color:#444}.focus\:border-white\/50:focus{border-color:#ffffff80}@supports (color:color-mix(in lab,red,red)){.focus\:border-white\/50:focus{border-color:color-mix(in oklab,var(--color-white) 50%,transparent)}}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-white\/10:focus{--tw-ring-color:#ffffff1a}@supports (color:color-mix(in lab,red,red)){.focus\:ring-white\/10:focus{--tw-ring-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-white\/20:focus-visible{--tw-ring-color:#fff3}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-white\/20:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:opacity-60:disabled{opacity:.6}@media(min-width:40rem){.sm\:rounded-lg{border-radius:var(--radius-lg)}.sm\:p-8{padding:calc(var(--spacing) * 8)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-12{padding-block:calc(var(--spacing) * 12)}}@media(min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(min-width:64rem){.lg\:sticky{position:sticky}.lg\:top-6{top:calc(var(--spacing) * 6)}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:max-h-\[calc\(100vh-3rem\)\]{max-height:calc(100vh - 3rem)}.lg\:grid-cols-\[1fr_340px\]{grid-template-columns:1fr 340px}.lg\:overflow-y-auto{overflow-y:auto}.lg\:border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.lg\:border-\[\#2a2a2a\]{border-color:#2a2a2a}.lg\:pl-6{padding-left:calc(var(--spacing) * 6)}}.\[\&_svg\]\:h-3\.5 svg{height:calc(var(--spacing) * 3.5)}.\[\&_svg\]\:w-3\.5 svg{width:calc(var(--spacing) * 3.5)}}:root{--font-geist-sans:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;--font-geist-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace}html,body{color:#fff;font-family:var(--font-geist-sans);background:#000}.scrollbar-thin{scrollbar-width:thin;scrollbar-color:#ffffff26 transparent}.scrollbar-thin::-webkit-scrollbar{width:6px;height:6px}.scrollbar-thin::-webkit-scrollbar-thumb{background:#ffffff26;border-radius:3px}.scrollbar-thin::-webkit-scrollbar-track{background:0 0}@keyframes page-in{0%{opacity:0;filter:blur(8px);transform:translateY(8px)}to{opacity:1;filter:blur();transform:translateY(0)}}.animate-page-in{animation:.15s ease-out page-in}@keyframes fade-in{0%{opacity:0}to{opacity:1}}.animate-fade-in{animation:.35s ease-out fade-in}@keyframes cardIn{0%{opacity:0;filter:blur(4px);transform:translateY(8px)scale(.97)}to{opacity:1;filter:blur();transform:translateY(0)scale(1)}}.animate-card-in{opacity:0;animation:.3s cubic-bezier(.16,1,.3,1) forwards cardIn}.animate-card-in:first-child{animation-delay:0s}.animate-card-in:nth-child(2){animation-delay:50ms}.animate-card-in:nth-child(3){animation-delay:.1s}.animate-card-in:nth-child(4){animation-delay:.15s}@keyframes shimmer{0%{transform:translate(-100%)}to{transform:translate(400%)}}.animate-shimmer{animation:2s infinite shimmer}@keyframes dialog-overlay-in{0%{opacity:0}to{opacity:1}}@keyframes dialog-overlay-out{0%{opacity:1}to{opacity:0}}@keyframes dialog-panel-in{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}@keyframes dialog-panel-out{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.95)}}.dialog-overlay[data-state=open]{animation:.2s dialog-overlay-in}.dialog-overlay[data-state=closed]{animation:.2s forwards dialog-overlay-out}.dialog-panel[data-state=open]{animation:.2s dialog-panel-in}.dialog-panel[data-state=closed]{animation:.2s forwards dialog-panel-out}.agent-modal[data-state=open]{animation:.14s dialog-overlay-in}.agent-modal[data-state=closed]{animation:.14s forwards dialog-overlay-out}@keyframes tab-in{0%{opacity:0;filter:blur(4px);transform:translateY(6px)}to{opacity:1;filter:blur();transform:translateY(0)}}.animate-tab-in{animation:.2s ease-out tab-in}.prose-markdown{color:#999;word-wrap:break-word;overflow-wrap:break-word;font-size:14px;line-height:1.7}.prose-markdown p{margin-bottom:.75em}.prose-markdown p:last-child{margin-bottom:0}.prose-markdown strong{color:#ccc;font-weight:600}.prose-markdown em{font-style:italic}.prose-markdown code{color:#ccc;font-variant-ligatures:none;background:#0a0a0a;border:1px solid #111;border-radius:4px;padding:.15em .4em;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:.9em}.prose-markdown pre{font-variant-ligatures:none;background:0 0;border:none;border-radius:0;margin:0;padding:0}.prose-markdown pre code{color:inherit;background:0 0;border:none;padding:0;font-size:13px}.prose-markdown ul,.prose-markdown ol{margin-bottom:.75em;padding-left:1.5em}.prose-markdown ul{list-style-type:disc}.prose-markdown ol{list-style-type:decimal}.prose-markdown li{margin-bottom:.25em}.prose-markdown li>ul,.prose-markdown li>ol{margin-top:.25em;margin-bottom:.25em;padding-left:1.5em}.prose-markdown ol+ul{margin-top:-.5em;padding-left:3em}.prose-markdown h1,.prose-markdown h2,.prose-markdown h3,.prose-markdown h4,.prose-markdown h5,.prose-markdown h6{color:#ddd;margin-top:1em;margin-bottom:.5em;font-weight:600}.prose-markdown a{color:inherit;pointer-events:none;text-decoration:none}.prose-markdown blockquote{color:#777;border-left:3px solid #333;margin:.75em 0;padding-left:1em}.prose-markdown hr{border:none;border-top:1px solid #222;margin:1em 0}.prose-markdown>table{border-collapse:collapse;width:100%;margin:.75em 0}.prose-markdown>table th,.prose-markdown>table td{text-align:left;border:1px solid #333;padding:.4em .75em;font-size:13px}.prose-markdown>table th{color:#ccc;background:#1a1a1a;font-weight:600}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}} diff --git a/strix/interface/viewer/static/assets/index-CGvQq6oe.js b/strix/interface/viewer/static/assets/index-DBJ-RJqo.js similarity index 98% rename from strix/interface/viewer/static/assets/index-CGvQq6oe.js rename to strix/interface/viewer/static/assets/index-DBJ-RJqo.js index a6e52674..ecdc0fcf 100644 --- a/strix/interface/viewer/static/assets/index-CGvQq6oe.js +++ b/strix/interface/viewer/static/assets/index-DBJ-RJqo.js @@ -473,15 +473,15 @@ https://github.com/highlightjs/highlight.js/issues/2277`),Ht=ye,ft=Le),Qe===void `)}function e7({toolName:e,args:t,result:r}){const a=e==="write_stdin",s=a?t.chars??t.input??"":t.command??t.cmd??"",o=r;let c=null,d=null,h=null;if(o&&typeof o=="object"){c=typeof o.content=="string"?o.content:null,d=typeof o.error=="string"?o.error:null,h=typeof o.exit_code=="number"?o.exit_code:null;const m=typeof o.status=="string"?o.status:"";(m==="running"||m==="command still running")&&(c=null)}else typeof o=="string"&&(c=o);const f=c?JB(WB(c,s)):null;return g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/80 font-semibold text-sm",children:a?"Terminal input":"Terminal"}),s&&g.jsx(dg,{code:s,language:"bash",collapsible:!0}),d&&g.jsx(wi,{className:"text-red-400/70",children:d}),f&&g.jsx(wi,{className:"text-[#666]",children:f}),h!=null&&h!==0&&g.jsxs("div",{className:"font-mono text-[13px] text-red-400/70 mt-0.5",children:["exit code ",h]})]})}const n_={back:"going back in browser history",forward:"going forward in browser history",scroll_down:"scrolling down",scroll_up:"scrolling up",refresh:"refreshing",close_tab:"closing tab",switch_tab:"switching tab",list_tabs:"listing tabs",view_source:"viewing page source",get_console_logs:"getting console logs",screenshot:"taking screenshot",wait:"waiting...",close:"closing"},r_={click:"clicking",double_click:"double clicking",hover:"hovering"};function Rm({prefix:e,url:t,suffix:r}){return g.jsxs("span",{className:"text-[#888] text-[13px]",children:[e,t&&g.jsx("a",{href:t,target:"_blank",rel:"noopener noreferrer",className:"text-cyan-400/80 hover:underline",children:t}),r]})}function t7(e){const t=e.action??"",r=e.url??void 0;if(t in n_)return n_[t];if(t==="launch")return r?g.jsx(Rm,{prefix:"launching ",url:r}):"launching";if(t==="goto"||t==="navigate")return g.jsx(Rm,{prefix:"navigating to ",url:r});if(t==="new_tab")return g.jsx(Rm,{prefix:"opening tab ",url:r});if(t in r_)return r_[t];if(t==="type")return`typing "${(e.text??"").slice(0,40)}"`;if(t==="press_key"||t==="key_press")return`pressing key ${e.key??""}`;if(t==="save_pdf"||t==="save_as_pdf"){const a=e.file_path??"";return`saving PDF${a?` to ${a}`:""}`}return t==="execute_js"?"executing javascript":t||"browser action"}function n7({args:e}){const r=(e.action??"")==="execute_js"?e.js_code??e.code??"":"",a=t7(e);return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2 min-w-0",children:[g.jsx("span",{className:"text-blue-400/80 font-semibold text-sm shrink-0",children:"Browser"}),g.jsx("span",{className:"min-w-0 truncate text-[#888] text-[13px]",children:a})]}),r&&g.jsx(dg,{code:r,language:"javascript",collapsible:!0})]})}function fg(e){return e.length>60?"..."+e.slice(-57):e}const fu=30;function r7({toolName:e,args:t}){const r=t.path??t.file_path??"",a=t.command??"",s=t.old_str??"",o=t.new_str??"",c=t.regex??"";let d;e==="list_files"?d="list":e==="search_files"?d="search":a==="view"?d="view":a==="create"?d="create":a==="str_replace"?d="edit":a==="undo_edit"?d="undo":a==="insert"?d="insert":d="file";const h=r?fg(r):"",f=c?` /${c}/`:"",m=s?s.split(` `):[],p=o?o.split(` `):[],y=m.length+p.length,x=y>fu,_=x?Math.round(fu*(m.length/y)):m.length,N=x?fu-_:p.length;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-baseline gap-2",children:[g.jsx("span",{className:"text-sky-400/80 font-semibold text-sm shrink-0",children:d}),h&&g.jsx("span",{className:"text-[#888] font-mono text-[13px] break-all",children:h})]}),f&&g.jsx("div",{className:"text-purple-400/60 font-mono text-[13px] break-all mt-0.5",children:f}),(s||o)&&g.jsxs("div",{className:"font-mono text-[13px] leading-relaxed mt-1.5",children:[m.slice(0,_).map((S,w)=>g.jsxs("div",{className:"text-red-400/60",children:[g.jsx("span",{className:"select-none text-red-400/30 mr-1",children:"-"}),S]},`o${w}`)),p.slice(0,N).map((S,w)=>g.jsxs("div",{className:"text-emerald-400/60",children:[g.jsx("span",{className:"select-none text-emerald-400/30 mr-1",children:"+"}),S]},`n${w}`)),x&&g.jsxs("div",{className:"text-[#444] mt-0.5",children:["... ",y-fu," more lines"]})]})]})}const hu=30,i7="*** Begin Patch",a7="*** End Patch",i_="*** Add File: ",a_="*** Update File: ",s_="*** Delete File: ",s7={add:"create",update:"edit",delete:"delete"};function l7(e){const t=e.patch;return typeof t=="string"?t:t&&typeof t=="object"&&typeof t.patch=="string"?t.patch:typeof e.input=="string"?e.input:""}function o7(e){const t=[];let r=null;const a=()=>{r&&t.push(r),r=null};for(const s of e.split(` -`))if(!(s===i7||s===a7))if(s.startsWith(i_))a(),r={kind:"add",path:s.slice(i_.length).trim(),oldLines:[],newLines:[]};else if(s.startsWith(a_))a(),r={kind:"update",path:s.slice(a_.length).trim(),oldLines:[],newLines:[]};else if(s.startsWith(s_))a(),r={kind:"delete",path:s.slice(s_.length).trim(),oldLines:[],newLines:[]};else if((r==null?void 0:r.kind)==="update"){if(s.startsWith("@@"))continue;s.startsWith("-")&&!s.startsWith("---")?r.oldLines.push(s.slice(1)):s.startsWith("+")&&!s.startsWith("+++")&&r.newLines.push(s.slice(1))}else(r==null?void 0:r.kind)==="add"&&(s.startsWith("+")?r.newLines.push(s.slice(1)):s.trim()&&r.newLines.push(s));return a(),t}function c7({op:e}){const t=s7[e.kind]??"file",r=e.oldLines.length+e.newLines.length,a=r>hu,s=a&&r>0?Math.round(hu*(e.oldLines.length/r)):e.oldLines.length,o=a?hu-s:e.newLines.length;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-baseline gap-2",children:[g.jsx("span",{className:"text-sky-400/80 font-semibold text-sm shrink-0",children:t}),e.path&&g.jsx("span",{className:"text-[#888] font-mono text-[13px] break-all",children:fg(e.path)})]}),(e.oldLines.length>0||e.newLines.length>0)&&g.jsxs("div",{className:"font-mono text-[13px] leading-relaxed mt-1.5",children:[e.oldLines.slice(0,s).map((c,d)=>g.jsxs("div",{className:"text-red-400/60",children:[g.jsx("span",{className:"select-none text-red-400/30 mr-1",children:"-"}),c]},`o${d}`)),e.newLines.slice(0,o).map((c,d)=>g.jsxs("div",{className:"text-emerald-400/60",children:[g.jsx("span",{className:"select-none text-emerald-400/30 mr-1",children:"+"}),c]},`n${d}`)),a&&g.jsxs("div",{className:"text-[#444] mt-0.5",children:["... ",r-hu," more lines"]})]})]})}function u7({args:e,result:t,status:r}){const a=o7(l7(e));return a.length===0?g.jsxs("div",{children:[g.jsx("span",{className:"text-sky-400/80 font-semibold text-sm",children:"patch"}),r==="failed"&&typeof t=="string"&&t.trim()&&g.jsx("div",{className:"text-red-400/70 text-[13px] mt-1",children:t.trim()})]}):g.jsxs("div",{className:"space-y-2",children:[a.map((s,o)=>g.jsx(c7,{op:s},o)),r==="failed"&&typeof t=="string"&&t.trim()&&g.jsx("div",{className:"text-red-400/70 text-[13px]",children:t.trim()})]})}function d7({args:e,result:t}){const r=(e.path??"").trim(),a=t;let s=null;if(typeof a=="string"){const o=a.trim();o&&!o.toLowerCase().startsWith("data:image/")&&!o.startsWith("{")&&(s=o)}return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-baseline gap-2",children:[g.jsx("span",{className:"text-sky-400/80 font-semibold text-sm shrink-0",children:"view image"}),r&&g.jsx("span",{className:"text-[#888] font-mono text-[13px] break-all",children:fg(r)})]}),s&&g.jsx("div",{className:"text-red-400/70 text-[13px] mt-1",children:s})]})}const f7={critical:"text-red-400",high:"text-orange-400",medium:"text-yellow-400",low:"text-blue-400",info:"text-cyan-400"};function h7({args:e,result:t}){const r=e.title??"",a=e.description??"",s=e.impact??"",o=e.target??"",c=e.endpoint??"",d=e.method??"",h=e.technical_analysis??"",f=e.poc_description??"",{language:m,code:p}=oE(e.poc_script_code??""),y=e.remediation_steps??"",x=e.cve??"",_=e.cwe??"",N=t,S=(N&&typeof N=="object"?N.severity:null)??e.severity??"medium",w=String(S).toLowerCase(),k=(N&&typeof N=="object"?N.cvss_score:null)??e.cvss??null,E=f7[w]??"text-yellow-400";return g.jsxs("div",{className:"space-y-3",children:[g.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[g.jsx("span",{className:`font-semibold text-sm ${E}`,children:w.toUpperCase()}),k!=null&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["CVSS ",k]}),x&&g.jsx("span",{className:"text-[#888] font-mono text-[13px]",children:x}),_&&g.jsx("span",{className:"text-[#888] font-mono text-[13px]",children:_})]}),r&&g.jsx("div",{className:"text-[15px] text-white/80 font-semibold",children:r}),(o||c)&&g.jsxs("div",{className:"text-[13px] text-[#888] font-mono",children:[o,c?` ${d} ${c}`:""]}),a&&g.jsx(An,{text:a,maxLines:20}),s&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Impact"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:s,maxLines:15})})]}),h&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Technical Analysis"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:h,maxLines:20})})]}),(f||p)&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Proof of Concept"}),f&&g.jsx("div",{className:"mt-1",children:g.jsx(ua,{text:f})}),p&&g.jsx(lE,{className:m?`language-${m}`:void 0,children:p})]}),y&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Remediation"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:y,maxLines:15})})]})]})}const m7={critical:"text-red-400",high:"text-orange-400",medium:"text-yellow-400",low:"text-blue-400",info:"text-cyan-400",none:"text-[#888]"};function p7(e){if(!e.agent_name&&!e.by_you)return null;const t=e.by_you?"you":e.agent_name;return g.jsxs("span",{className:"text-[#666] text-xs ml-1.5",children:["(",t,")"]})}function jm(e){const t=String(e??"").toLowerCase(),r=m7[t]??"text-yellow-400";return g.jsx("span",{className:`font-semibold text-[13px] ${r}`,children:t.toUpperCase()||"—"})}function l_({toolName:e,result:t}){const r=t,a=r!=null&&typeof r=="object"&&r.success===!0;if(e==="get_report"){const f=a?r.report:void 0;return g.jsxs("div",{children:[g.jsx("span",{className:"text-red-400/80 font-semibold text-sm",children:"report"}),f?g.jsxs("div",{className:"mt-1.5 space-y-2",children:[g.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[jm(f.severity),f.cvss!=null&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["CVSS ",f.cvss]}),f.id&&g.jsx("span",{className:"text-[#555] font-mono text-[13px]",children:f.id}),f.cve&&g.jsx("span",{className:"text-[#888] font-mono text-[13px]",children:f.cve}),f.cwe&&g.jsx("span",{className:"text-[#888] font-mono text-[13px]",children:f.cwe}),(f.agent_name||f.by_you)&&g.jsx("span",{className:"text-[#666] text-[13px]",children:f.by_you?"you":f.agent_name})]}),f.title&&g.jsx("div",{className:"text-[15px] text-white/80 font-semibold",children:f.title}),(f.target||f.endpoint)&&g.jsxs("div",{className:"text-[13px] text-[#888] font-mono",children:[f.target,f.endpoint?` ${f.method??""} ${f.endpoint}`:""]}),f.description&&g.jsx(An,{text:f.description,maxLines:20})]}):g.jsx("div",{className:"mt-1 text-[#555] text-xs",children:r&&typeof r=="object"&&r.error||"Report not found"})]})}const s=a?r.reports:null,o=Array.isArray(s)?s:[],c=a&&typeof r.total_count=="number"?r.total_count:o.length,d=a&&r.severity_counts&&typeof r.severity_counts=="object"?r.severity_counts:{},h=Object.entries(d);return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[g.jsx("span",{className:"text-red-400/80 font-semibold text-sm",children:"reports"}),g.jsxs("span",{className:"text-[#555] text-[13px]",children:["(",c,")"]}),h.map(([f,m])=>g.jsxs("span",{className:"text-[13px]",children:[jm(f),g.jsx("span",{className:"text-[#888] ml-0.5",children:m})]},f))]}),o.length>0?g.jsx("div",{className:"mt-1.5 space-y-1",children:o.map((f,m)=>g.jsxs("div",{className:"text-[13px]",children:[g.jsx("span",{className:"text-[#555] mr-1",children:"-"}),jm(f.severity),f.id&&g.jsx("span",{className:"text-[#555] font-mono ml-1.5",children:f.id}),g.jsx("span",{className:"text-[#999] ml-1.5",children:f.title??"(untitled)"}),p7(f),(f.target||f.endpoint)&&g.jsxs("div",{className:"ml-3 text-[#666] font-mono text-xs",children:[f.target,f.endpoint?` ${f.method??""} ${f.endpoint}`:""]}),f.description_preview&&g.jsx("div",{className:"ml-3",children:g.jsx(ua,{text:f.description_preview})})]},f.id??m))}):g.jsx("div",{className:"mt-1 text-[#555] text-xs",children:"No reports filed yet"})]})}const eS=200,tS={GET:"text-emerald-400/80",POST:"text-blue-400/80",PUT:"text-yellow-400/80",PATCH:"text-orange-400/80",DELETE:"text-red-400/80"};function hg(e){return e<300?"text-emerald-400/80":e<400?"text-yellow-400/80":e<500?"text-orange-400/80":"text-red-400/80"}function Xr(e,t=80){return e.length>t?e.slice(0,t-3)+"...":e}function gp(e,t=150){return Xr(e.replace(/\n/g," ").replace(/\r/g,"").replace(/\t/g," "),t)}function bp(e,t){const r=e.split(` +`))if(!(s===i7||s===a7))if(s.startsWith(i_))a(),r={kind:"add",path:s.slice(i_.length).trim(),oldLines:[],newLines:[]};else if(s.startsWith(a_))a(),r={kind:"update",path:s.slice(a_.length).trim(),oldLines:[],newLines:[]};else if(s.startsWith(s_))a(),r={kind:"delete",path:s.slice(s_.length).trim(),oldLines:[],newLines:[]};else if((r==null?void 0:r.kind)==="update"){if(s.startsWith("@@"))continue;s.startsWith("-")&&!s.startsWith("---")?r.oldLines.push(s.slice(1)):s.startsWith("+")&&!s.startsWith("+++")&&r.newLines.push(s.slice(1))}else(r==null?void 0:r.kind)==="add"&&(s.startsWith("+")?r.newLines.push(s.slice(1)):s.trim()&&r.newLines.push(s));return a(),t}function c7({op:e}){const t=s7[e.kind]??"file",r=e.oldLines.length+e.newLines.length,a=r>hu,s=a&&r>0?Math.round(hu*(e.oldLines.length/r)):e.oldLines.length,o=a?hu-s:e.newLines.length;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-baseline gap-2",children:[g.jsx("span",{className:"text-sky-400/80 font-semibold text-sm shrink-0",children:t}),e.path&&g.jsx("span",{className:"text-[#888] font-mono text-[13px] break-all",children:fg(e.path)})]}),(e.oldLines.length>0||e.newLines.length>0)&&g.jsxs("div",{className:"font-mono text-[13px] leading-relaxed mt-1.5",children:[e.oldLines.slice(0,s).map((c,d)=>g.jsxs("div",{className:"text-red-400/60",children:[g.jsx("span",{className:"select-none text-red-400/30 mr-1",children:"-"}),c]},`o${d}`)),e.newLines.slice(0,o).map((c,d)=>g.jsxs("div",{className:"text-emerald-400/60",children:[g.jsx("span",{className:"select-none text-emerald-400/30 mr-1",children:"+"}),c]},`n${d}`)),a&&g.jsxs("div",{className:"text-[#444] mt-0.5",children:["... ",r-hu," more lines"]})]})]})}function u7({args:e,result:t,status:r}){const a=o7(l7(e));return a.length===0?g.jsxs("div",{children:[g.jsx("span",{className:"text-sky-400/80 font-semibold text-sm",children:"patch"}),r==="failed"&&typeof t=="string"&&t.trim()&&g.jsx("div",{className:"text-red-400/70 text-[13px] mt-1",children:t.trim()})]}):g.jsxs("div",{className:"space-y-2",children:[a.map((s,o)=>g.jsx(c7,{op:s},o)),r==="failed"&&typeof t=="string"&&t.trim()&&g.jsx("div",{className:"text-red-400/70 text-[13px]",children:t.trim()})]})}const d7=/data:image\/(png|jpe?g|gif|webp);base64,([A-Za-z0-9+/]+={0,2})/;function f7(e){let t=null;if(typeof e=="string")t=e;else if(e&&typeof e=="object"){const a=e;typeof a.image_url=="string"?t=a.image_url:typeof a.url=="string"&&(t=a.url)}if(!t)return null;const r=d7.exec(t);return!r||r[2].length<100||r[2].length%4!==0?null:`data:image/${r[1]};base64,${r[2]}`}function h7({args:e,result:t}){const r=(e.path??"").trim(),a=f7(t);let s=null;if(!a&&typeof t=="string"){const o=t.trim();o&&!o.toLowerCase().startsWith("data:image/")&&!o.startsWith("{")&&(s=o)}return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-baseline gap-2",children:[g.jsx("span",{className:"text-sky-400/80 font-semibold text-sm shrink-0",children:"view image"}),r&&g.jsx("span",{className:"text-[#888] font-mono text-[13px] break-all",children:fg(r)})]}),a&&g.jsx("img",{src:a,alt:r||"Tool image output",className:"mt-1.5 max-w-full max-h-96 rounded-lg border border-white/[0.06] object-contain"}),s&&g.jsx("div",{className:"text-red-400/70 text-[13px] mt-1",children:s})]})}const m7={critical:"text-red-400",high:"text-orange-400",medium:"text-yellow-400",low:"text-blue-400",info:"text-cyan-400"};function p7({args:e,result:t}){const r=e.title??"",a=e.description??"",s=e.impact??"",o=e.target??"",c=e.endpoint??"",d=e.method??"",h=e.technical_analysis??"",f=e.poc_description??"",{language:m,code:p}=oE(e.poc_script_code??""),y=e.remediation_steps??"",x=e.cve??"",_=e.cwe??"",N=t,S=(N&&typeof N=="object"?N.severity:null)??e.severity??"medium",w=String(S).toLowerCase(),k=(N&&typeof N=="object"?N.cvss_score:null)??e.cvss??null,E=m7[w]??"text-yellow-400";return g.jsxs("div",{className:"space-y-3",children:[g.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[g.jsx("span",{className:`font-semibold text-sm ${E}`,children:w.toUpperCase()}),k!=null&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["CVSS ",k]}),x&&g.jsx("span",{className:"text-[#888] font-mono text-[13px]",children:x}),_&&g.jsx("span",{className:"text-[#888] font-mono text-[13px]",children:_})]}),r&&g.jsx("div",{className:"text-[15px] text-white/80 font-semibold",children:r}),(o||c)&&g.jsxs("div",{className:"text-[13px] text-[#888] font-mono",children:[o,c?` ${d} ${c}`:""]}),a&&g.jsx(An,{text:a,maxLines:20}),s&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Impact"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:s,maxLines:15})})]}),h&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Technical Analysis"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:h,maxLines:20})})]}),(f||p)&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Proof of Concept"}),f&&g.jsx("div",{className:"mt-1",children:g.jsx(ua,{text:f})}),p&&g.jsx(lE,{className:m?`language-${m}`:void 0,children:p})]}),y&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Remediation"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:y,maxLines:15})})]})]})}const g7={critical:"text-red-400",high:"text-orange-400",medium:"text-yellow-400",low:"text-blue-400",info:"text-cyan-400",none:"text-[#888]"};function b7(e){if(!e.agent_name&&!e.by_you)return null;const t=e.by_you?"you":e.agent_name;return g.jsxs("span",{className:"text-[#666] text-xs ml-1.5",children:["(",t,")"]})}function jm(e){const t=String(e??"").toLowerCase(),r=g7[t]??"text-yellow-400";return g.jsx("span",{className:`font-semibold text-[13px] ${r}`,children:t.toUpperCase()||"—"})}function l_({toolName:e,result:t}){const r=t,a=r!=null&&typeof r=="object"&&r.success===!0;if(e==="get_report"){const f=a?r.report:void 0;return g.jsxs("div",{children:[g.jsx("span",{className:"text-red-400/80 font-semibold text-sm",children:"report"}),f?g.jsxs("div",{className:"mt-1.5 space-y-2",children:[g.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[jm(f.severity),f.cvss!=null&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["CVSS ",f.cvss]}),f.id&&g.jsx("span",{className:"text-[#555] font-mono text-[13px]",children:f.id}),f.cve&&g.jsx("span",{className:"text-[#888] font-mono text-[13px]",children:f.cve}),f.cwe&&g.jsx("span",{className:"text-[#888] font-mono text-[13px]",children:f.cwe}),(f.agent_name||f.by_you)&&g.jsx("span",{className:"text-[#666] text-[13px]",children:f.by_you?"you":f.agent_name})]}),f.title&&g.jsx("div",{className:"text-[15px] text-white/80 font-semibold",children:f.title}),(f.target||f.endpoint)&&g.jsxs("div",{className:"text-[13px] text-[#888] font-mono",children:[f.target,f.endpoint?` ${f.method??""} ${f.endpoint}`:""]}),f.description&&g.jsx(An,{text:f.description,maxLines:20})]}):g.jsx("div",{className:"mt-1 text-[#555] text-xs",children:r&&typeof r=="object"&&r.error||"Report not found"})]})}const s=a?r.reports:null,o=Array.isArray(s)?s:[],c=a&&typeof r.total_count=="number"?r.total_count:o.length,d=a&&r.severity_counts&&typeof r.severity_counts=="object"?r.severity_counts:{},h=Object.entries(d);return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[g.jsx("span",{className:"text-red-400/80 font-semibold text-sm",children:"reports"}),g.jsxs("span",{className:"text-[#555] text-[13px]",children:["(",c,")"]}),h.map(([f,m])=>g.jsxs("span",{className:"text-[13px]",children:[jm(f),g.jsx("span",{className:"text-[#888] ml-0.5",children:m})]},f))]}),o.length>0?g.jsx("div",{className:"mt-1.5 space-y-1",children:o.map((f,m)=>g.jsxs("div",{className:"text-[13px]",children:[g.jsx("span",{className:"text-[#555] mr-1",children:"-"}),jm(f.severity),f.id&&g.jsx("span",{className:"text-[#555] font-mono ml-1.5",children:f.id}),g.jsx("span",{className:"text-[#999] ml-1.5",children:f.title??"(untitled)"}),b7(f),(f.target||f.endpoint)&&g.jsxs("div",{className:"ml-3 text-[#666] font-mono text-xs",children:[f.target,f.endpoint?` ${f.method??""} ${f.endpoint}`:""]}),f.description_preview&&g.jsx("div",{className:"ml-3",children:g.jsx(ua,{text:f.description_preview})})]},f.id??m))}):g.jsx("div",{className:"mt-1 text-[#555] text-xs",children:"No reports filed yet"})]})}const eS=200,tS={GET:"text-emerald-400/80",POST:"text-blue-400/80",PUT:"text-yellow-400/80",PATCH:"text-orange-400/80",DELETE:"text-red-400/80"};function hg(e){return e<300?"text-emerald-400/80":e<400?"text-yellow-400/80":e<500?"text-orange-400/80":"text-red-400/80"}function Xr(e,t=80){return e.length>t?e.slice(0,t-3)+"...":e}function gp(e,t=150){return Xr(e.replace(/\n/g," ").replace(/\r/g,"").replace(/\t/g," "),t)}function bp(e,t){const r=e.split(` `),a=r.slice(0,t).map(s=>Xr(s,eS-5)).join(` `);return r.length>t?a+` -...`:a}function g7({args:e,result:t}){const r=e.httpql_filter??"",a=t,s=a?a.requests:null,o=Array.isArray(s)?s:[];return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"listing requests"}),r&&g.jsx("span",{className:"text-[#888] text-[13px]",children:Xr(r,150)})]}),o.length>0&&g.jsxs("div",{className:"mt-1.5 font-mono text-[13px] space-y-0.5",children:[o.slice(0,20).map((c,d)=>{const h=(c.method??"GET").toUpperCase(),f=c.host??"",m=c.path??"",p=c.response,y=(p==null?void 0:p.statusCode)??null;return g.jsxs("div",{className:"flex gap-2",children:[g.jsx("span",{className:`w-10 shrink-0 font-bold ${tS[h]??"text-[#888]"}`,children:h}),g.jsx("span",{className:"text-[#777] truncate",children:Xr(f+m,180)}),y!=null&&g.jsx("span",{className:`ml-auto shrink-0 ${hg(y)}`,children:y})]},d)}),o.length>20&&g.jsxs("div",{className:"text-[#555]",children:["... +",o.length-20," more"]})]})]})}function b7({args:e,result:t}){const r=e.request_id,a=e.part??"request",s=e.search_pattern??"",o=t,c=o?o.matches:null,d=Array.isArray(c)?c:[],h=o?o.content??null:null,f=o?!!o.has_more:!1;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsxs("span",{className:"text-purple-400/80 font-semibold text-sm",children:[s?"searching":"viewing"," ",a]}),r!=null&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["#",r]}),s&&g.jsxs("span",{className:"text-[#666] font-mono text-[13px]",children:["/",Xr(s,100),"/"]})]}),d.length>0&&g.jsxs("div",{className:"mt-1.5 font-mono text-[13px] space-y-1",children:[d.slice(0,5).map((m,p)=>{const y=(m.before??"").replace(/\n/g," ").replace(/\r/g,"").slice(-100),x=(m.after??"").replace(/\n/g," ").replace(/\r/g,"").slice(0,100);return g.jsxs("div",{children:[y&&g.jsxs("span",{className:"text-[#555]",children:["...",y]}),g.jsx("span",{className:"text-amber-400/80 font-bold",children:m.match}),x&&g.jsxs("span",{className:"text-[#555]",children:[x,"..."]})]},p)}),d.length>5&&g.jsxs("div",{className:"text-[#555]",children:["... +",d.length-5," more matches"]})]}),h&&!d.length&&(()=>{const m=h.split(` +...`:a}function x7({args:e,result:t}){const r=e.httpql_filter??"",a=t,s=a?a.requests:null,o=Array.isArray(s)?s:[];return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"listing requests"}),r&&g.jsx("span",{className:"text-[#888] text-[13px]",children:Xr(r,150)})]}),o.length>0&&g.jsxs("div",{className:"mt-1.5 font-mono text-[13px] space-y-0.5",children:[o.slice(0,20).map((c,d)=>{const h=(c.method??"GET").toUpperCase(),f=c.host??"",m=c.path??"",p=c.response,y=(p==null?void 0:p.statusCode)??null;return g.jsxs("div",{className:"flex gap-2",children:[g.jsx("span",{className:`w-10 shrink-0 font-bold ${tS[h]??"text-[#888]"}`,children:h}),g.jsx("span",{className:"text-[#777] truncate",children:Xr(f+m,180)}),y!=null&&g.jsx("span",{className:`ml-auto shrink-0 ${hg(y)}`,children:y})]},d)}),o.length>20&&g.jsxs("div",{className:"text-[#555]",children:["... +",o.length-20," more"]})]})]})}function y7({args:e,result:t}){const r=e.request_id,a=e.part??"request",s=e.search_pattern??"",o=t,c=o?o.matches:null,d=Array.isArray(c)?c:[],h=o?o.content??null:null,f=o?!!o.has_more:!1;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsxs("span",{className:"text-purple-400/80 font-semibold text-sm",children:[s?"searching":"viewing"," ",a]}),r!=null&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["#",r]}),s&&g.jsxs("span",{className:"text-[#666] font-mono text-[13px]",children:["/",Xr(s,100),"/"]})]}),d.length>0&&g.jsxs("div",{className:"mt-1.5 font-mono text-[13px] space-y-1",children:[d.slice(0,5).map((m,p)=>{const y=(m.before??"").replace(/\n/g," ").replace(/\r/g,"").slice(-100),x=(m.after??"").replace(/\n/g," ").replace(/\r/g,"").slice(0,100);return g.jsxs("div",{children:[y&&g.jsxs("span",{className:"text-[#555]",children:["...",y]}),g.jsx("span",{className:"text-amber-400/80 font-bold",children:m.match}),x&&g.jsxs("span",{className:"text-[#555]",children:[x,"..."]})]},p)}),d.length>5&&g.jsxs("div",{className:"text-[#555]",children:["... +",d.length-5," more matches"]})]}),h&&!d.length&&(()=>{const m=h.split(` `),p=m.slice(0,15).map(x=>Xr(x,eS)).join(` `),y=f||m.length>15;return g.jsx(wi,{className:"text-[#666]",children:p+(y?` -... more content available`:"")})})()]})}function x7({args:e,result:t}){const r=(e.method??"GET").toUpperCase(),a=e.url??"",s=e.headers,o=e.body,c=typeof o=="string"?o:"",d=t,h=d?d.error??null:null,f=d?d.status_code??null:null,m=d?d.response_time_ms??null:null,p=d?d.body:null,y=typeof p=="string"?p:null;return g.jsxs("div",{children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"request"}),g.jsxs("div",{className:"mt-1.5 font-mono text-[13px] space-y-0.5",children:[g.jsxs("div",{children:[g.jsx("span",{className:"text-[#555] select-none mr-1",children:">>"}),g.jsx("span",{className:`font-bold ${tS[r]??"text-[#888]"}`,children:r}),g.jsx("span",{className:"text-[#888] ml-1 break-all",children:Xr(a,180)})]}),s&&typeof s=="object"&&Object.entries(s).slice(0,5).map(([x,_])=>g.jsxs("div",{className:"text-[#555] pl-5",children:[x,": ",gp(String(_),150)]},x))]}),c&&g.jsx(wi,{className:"text-[#888]",children:bp(c,4)}),h&&g.jsx("div",{className:"text-red-400/70 text-[13px] mt-1.5",children:gp(h,150)}),f!=null&&g.jsxs("div",{className:"font-mono text-[13px] mt-1.5",children:[g.jsx("span",{className:"text-[#555] select-none mr-1",children:"<<"}),g.jsx("span",{className:`font-bold ${hg(f)}`,children:f}),m!=null&&g.jsxs("span",{className:"text-[#555] ml-2",children:[m,"ms"]})]}),y&&g.jsx(wi,{className:"text-[#666]",children:bp(y,6)})]})}function y7({args:e,result:t}){const r=e.request_id,a=e.modifications,s=t,o=s?s.status_code??null:null,c=s?s.response_time_ms??null:null,d=s?s.body:null,h=typeof d=="string"?d:null;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"repeating request"}),r!=null&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["#",r]})]}),a&&typeof a=="object"&&Object.keys(a).length>0&&g.jsx("div",{className:"mt-1.5 font-mono text-[13px] space-y-0.5",children:Object.entries(a).slice(0,5).map(([f,m])=>g.jsxs("div",{children:[g.jsxs("span",{className:"text-orange-400/60",children:[f,":"]})," ",g.jsx("span",{className:"text-[#777]",children:gp(typeof m=="string"?m:JSON.stringify(m),150)})]},f))}),o!=null&&g.jsxs("div",{className:"font-mono text-[13px] mt-1.5",children:[g.jsx("span",{className:"text-[#555] select-none mr-1",children:"<<"}),g.jsx("span",{className:`font-bold ${hg(o)}`,children:o}),c!=null&&g.jsxs("span",{className:"text-[#555] ml-2",children:[c,"ms"]})]}),h&&g.jsx(wi,{className:"text-[#666]",children:bp(h,5)})]})}const v7={get:"getting",list:"listing",create:"creating",update:"updating",delete:"deleting"};function _7({args:e}){const t=e.action??"",r=e.scope_name??"",a=v7[t]??(t||"managing");return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsxs("span",{className:"text-purple-400/80 font-semibold text-sm",children:[a," proxy scope"]}),r&&g.jsx("span",{className:"text-[#888] text-[13px]",children:Xr(r,50)})]})}function w7({args:e}){const t=e.parent_id;return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"listing sitemap"}),t&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["under #",Xr(String(t),20)]})]})}function E7({args:e}){const t=e.entry_id;return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"viewing sitemap entry"}),t&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["#",Xr(String(t),20)]})]})}function N7(e){switch(e.toolName){case"list_requests":return g.jsx(g7,{...e});case"view_request":return g.jsx(b7,{...e});case"send_request":return g.jsx(x7,{...e});case"repeat_request":return g.jsx(y7,{...e});case"scope_rules":return g.jsx(_7,{...e});case"list_sitemap":return g.jsx(w7,{...e});case"view_sitemap_entry":return g.jsx(E7,{...e});default:return g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:e.toolName.replace(/_/g," ")})}}function S7({args:e}){const t=e.thought??e.content??"";return t?g.jsxs("div",{children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"Agent is thinking"}),g.jsx("div",{className:"mt-1.5 italic text-[#888]",children:g.jsx(An,{text:t,maxLines:20})})]}):null}function k7({toolName:e,args:t}){if(e==="create_agent"){const r=t.name??t.agent_name??"",a=t.task??"";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-cyan-400/80 font-semibold text-sm",children:"spawning"}),r&&g.jsx("span",{className:"text-cyan-400 font-semibold text-sm",children:r})]}),a&&g.jsx("div",{className:"mt-1.5",children:g.jsx(An,{text:a,maxLines:15})})]})}if(e==="agent_finish"){const r=t.result_summary??"",a=t.success,s=t.findings,o=Array.isArray(s)?s:void 0;return g.jsxs("div",{children:[g.jsx("span",{className:`font-semibold text-sm ${a===!1?"text-red-400/80":"text-emerald-400/80"}`,children:a===!1?"Agent failed":"Agent completed"}),r&&g.jsx("div",{className:"mt-1.5",children:g.jsx(An,{text:r,maxLines:20})}),o&&o.length>0&&g.jsx("div",{className:"mt-1.5 space-y-0.5",children:o.map((c,d)=>g.jsxs("div",{className:"text-[13px] text-[#888]",children:[g.jsx("span",{className:"text-red-400/50 mr-1",children:"•"}),typeof c=="string"?c:JSON.stringify(c)]},d))})]})}if(e==="send_message_to_agent"){const r=t.message??"",a=t.target_agent_id??t.agent_id??"";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-cyan-400/80 font-semibold text-sm",children:"message"}),a&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["to ",a.slice(0,16)]})]}),r&&g.jsx("div",{className:"mt-1.5",children:g.jsx(An,{text:r,maxLines:20})})]})}if(e==="wait_for_agents"){const r=t.reason??"";return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-cyan-400/80 font-semibold text-sm",children:"waiting"}),r&&g.jsx("span",{className:"text-[#888] text-[13px] truncate",children:r})]})}if(e==="stop_agent"){const r=t.target_agent_id??"",a=t.cascade!==!1,s=t.reason??"";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[g.jsx("span",{className:"text-red-400/80 font-semibold text-sm",children:"stopping"}),r&&g.jsx("span",{className:"text-[#888] text-[13px]",children:r.slice(0,16)}),a&&g.jsx("span",{className:"text-[#555] text-[13px] italic",children:"+ descendants"})]}),s&&g.jsx("div",{className:"mt-1.5 text-[#888] text-[13px]",children:s})]})}return e==="view_agent_graph"?g.jsx("span",{className:"text-cyan-400/80 font-semibold text-sm",children:"viewing agents graph"}):g.jsx("span",{className:"text-cyan-400/80 font-semibold text-sm",children:e.replace(/_/g," ")})}function C7({args:e,result:t}){const r=e.query??e.search_query??"",a=t,s=a?a.content??null:null,o=a&&!a.success?a.message??null:null;return g.jsxs("div",{children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"Searching the web"}),r&&g.jsx("div",{className:"text-[#888] text-[13px] mt-0.5",children:r}),o&&g.jsx("div",{className:"text-red-400/70 text-[13px] mt-1.5",children:o}),s&&g.jsx("div",{className:"mt-2",children:g.jsx(An,{text:s,maxLines:15})})]})}const T7=50,o_=200,c_=25,u_=24,A7=/\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)/g,M7=/\.\.\. \[(stdout|stderr|result|output|error) truncated at \d+k? chars\]/g;function O7(e){return e.replace(A7,"")}function Dm(e){const t=O7(e);return t.length>o_?t.slice(0,o_-3)+"...":t}function R7(e){return e.replace(M7,"").trim()}function j7(e){const t=e.split(` -`);if(t.length<=T7)return t.map(Dm).join(` +... more content available`:"")})})()]})}function v7({args:e,result:t}){const r=(e.method??"GET").toUpperCase(),a=e.url??"",s=e.headers,o=e.body,c=typeof o=="string"?o:"",d=t,h=d?d.error??null:null,f=d?d.status_code??null:null,m=d?d.response_time_ms??null:null,p=d?d.body:null,y=typeof p=="string"?p:null;return g.jsxs("div",{children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"request"}),g.jsxs("div",{className:"mt-1.5 font-mono text-[13px] space-y-0.5",children:[g.jsxs("div",{children:[g.jsx("span",{className:"text-[#555] select-none mr-1",children:">>"}),g.jsx("span",{className:`font-bold ${tS[r]??"text-[#888]"}`,children:r}),g.jsx("span",{className:"text-[#888] ml-1 break-all",children:Xr(a,180)})]}),s&&typeof s=="object"&&Object.entries(s).slice(0,5).map(([x,_])=>g.jsxs("div",{className:"text-[#555] pl-5",children:[x,": ",gp(String(_),150)]},x))]}),c&&g.jsx(wi,{className:"text-[#888]",children:bp(c,4)}),h&&g.jsx("div",{className:"text-red-400/70 text-[13px] mt-1.5",children:gp(h,150)}),f!=null&&g.jsxs("div",{className:"font-mono text-[13px] mt-1.5",children:[g.jsx("span",{className:"text-[#555] select-none mr-1",children:"<<"}),g.jsx("span",{className:`font-bold ${hg(f)}`,children:f}),m!=null&&g.jsxs("span",{className:"text-[#555] ml-2",children:[m,"ms"]})]}),y&&g.jsx(wi,{className:"text-[#666]",children:bp(y,6)})]})}function _7({args:e,result:t}){const r=e.request_id,a=e.modifications,s=t,o=s?s.status_code??null:null,c=s?s.response_time_ms??null:null,d=s?s.body:null,h=typeof d=="string"?d:null;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"repeating request"}),r!=null&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["#",r]})]}),a&&typeof a=="object"&&Object.keys(a).length>0&&g.jsx("div",{className:"mt-1.5 font-mono text-[13px] space-y-0.5",children:Object.entries(a).slice(0,5).map(([f,m])=>g.jsxs("div",{children:[g.jsxs("span",{className:"text-orange-400/60",children:[f,":"]})," ",g.jsx("span",{className:"text-[#777]",children:gp(typeof m=="string"?m:JSON.stringify(m),150)})]},f))}),o!=null&&g.jsxs("div",{className:"font-mono text-[13px] mt-1.5",children:[g.jsx("span",{className:"text-[#555] select-none mr-1",children:"<<"}),g.jsx("span",{className:`font-bold ${hg(o)}`,children:o}),c!=null&&g.jsxs("span",{className:"text-[#555] ml-2",children:[c,"ms"]})]}),h&&g.jsx(wi,{className:"text-[#666]",children:bp(h,5)})]})}const w7={get:"getting",list:"listing",create:"creating",update:"updating",delete:"deleting"};function E7({args:e}){const t=e.action??"",r=e.scope_name??"",a=w7[t]??(t||"managing");return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsxs("span",{className:"text-purple-400/80 font-semibold text-sm",children:[a," proxy scope"]}),r&&g.jsx("span",{className:"text-[#888] text-[13px]",children:Xr(r,50)})]})}function N7({args:e}){const t=e.parent_id;return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"listing sitemap"}),t&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["under #",Xr(String(t),20)]})]})}function S7({args:e}){const t=e.entry_id;return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"viewing sitemap entry"}),t&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["#",Xr(String(t),20)]})]})}function k7(e){switch(e.toolName){case"list_requests":return g.jsx(x7,{...e});case"view_request":return g.jsx(y7,{...e});case"send_request":return g.jsx(v7,{...e});case"repeat_request":return g.jsx(_7,{...e});case"scope_rules":return g.jsx(E7,{...e});case"list_sitemap":return g.jsx(N7,{...e});case"view_sitemap_entry":return g.jsx(S7,{...e});default:return g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:e.toolName.replace(/_/g," ")})}}function C7({args:e}){const t=e.thought??e.content??"";return t?g.jsxs("div",{children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"Agent is thinking"}),g.jsx("div",{className:"mt-1.5 italic text-[#888]",children:g.jsx(An,{text:t,maxLines:20})})]}):null}function T7({toolName:e,args:t}){if(e==="create_agent"){const r=t.name??t.agent_name??"",a=t.task??"";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-cyan-400/80 font-semibold text-sm",children:"spawning"}),r&&g.jsx("span",{className:"text-cyan-400 font-semibold text-sm",children:r})]}),a&&g.jsx("div",{className:"mt-1.5",children:g.jsx(An,{text:a,maxLines:15})})]})}if(e==="agent_finish"){const r=t.result_summary??"",a=t.success,s=t.findings,o=Array.isArray(s)?s:void 0;return g.jsxs("div",{children:[g.jsx("span",{className:`font-semibold text-sm ${a===!1?"text-red-400/80":"text-emerald-400/80"}`,children:a===!1?"Agent failed":"Agent completed"}),r&&g.jsx("div",{className:"mt-1.5",children:g.jsx(An,{text:r,maxLines:20})}),o&&o.length>0&&g.jsx("div",{className:"mt-1.5 space-y-0.5",children:o.map((c,d)=>g.jsxs("div",{className:"text-[13px] text-[#888]",children:[g.jsx("span",{className:"text-red-400/50 mr-1",children:"•"}),typeof c=="string"?c:JSON.stringify(c)]},d))})]})}if(e==="send_message_to_agent"){const r=t.message??"",a=t.target_agent_id??t.agent_id??"";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-cyan-400/80 font-semibold text-sm",children:"message"}),a&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["to ",a.slice(0,16)]})]}),r&&g.jsx("div",{className:"mt-1.5",children:g.jsx(An,{text:r,maxLines:20})})]})}if(e==="wait_for_agents"){const r=t.reason??"";return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-cyan-400/80 font-semibold text-sm",children:"waiting"}),r&&g.jsx("span",{className:"text-[#888] text-[13px] truncate",children:r})]})}if(e==="stop_agent"){const r=t.target_agent_id??"",a=t.cascade!==!1,s=t.reason??"";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[g.jsx("span",{className:"text-red-400/80 font-semibold text-sm",children:"stopping"}),r&&g.jsx("span",{className:"text-[#888] text-[13px]",children:r.slice(0,16)}),a&&g.jsx("span",{className:"text-[#555] text-[13px] italic",children:"+ descendants"})]}),s&&g.jsx("div",{className:"mt-1.5 text-[#888] text-[13px]",children:s})]})}return e==="view_agent_graph"?g.jsx("span",{className:"text-cyan-400/80 font-semibold text-sm",children:"viewing agents graph"}):g.jsx("span",{className:"text-cyan-400/80 font-semibold text-sm",children:e.replace(/_/g," ")})}function A7({args:e,result:t}){const r=e.query??e.search_query??"",a=t,s=a?a.content??null:null,o=a&&!a.success?a.message??null:null;return g.jsxs("div",{children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"Searching the web"}),r&&g.jsx("div",{className:"text-[#888] text-[13px] mt-0.5",children:r}),o&&g.jsx("div",{className:"text-red-400/70 text-[13px] mt-1.5",children:o}),s&&g.jsx("div",{className:"mt-2",children:g.jsx(An,{text:s,maxLines:15})})]})}const M7=50,o_=200,c_=25,u_=24,O7=/\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)/g,R7=/\.\.\. \[(stdout|stderr|result|output|error) truncated at \d+k? chars\]/g;function j7(e){return e.replace(O7,"")}function Dm(e){const t=j7(e);return t.length>o_?t.slice(0,o_-3)+"...":t}function D7(e){return e.replace(R7,"").trim()}function L7(e){const t=e.split(` +`);if(t.length<=M7)return t.map(Dm).join(` `);const r=t.length-c_-u_;return[...t.slice(0,c_).map(Dm),`... ${r} lines truncated ...`,...t.slice(-u_).map(Dm)].join(` -`)}function D7({args:e,result:t}){const r=e.action??"",a=e.code??e.script??"",s=t;let o=null;s&&typeof s=="object"?o=typeof s.stdout=="string"?s.stdout:null:typeof s=="string"&&(o=s);const c=r==="new_session"?"new session":r==="close"?"close session":r==="list_sessions"?"list sessions":null,d=o?j7(R7(o)):null;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-yellow-400/80 font-semibold text-sm",children:"Python"}),c&&g.jsx("span",{className:"text-[#888] text-[13px]",children:c})]}),a&&g.jsx(dg,{code:a,language:"python",collapsible:!0}),d&&g.jsx(wi,{className:"text-[#666]",children:d})]})}function L7({args:e}){const t=e.targets,a=(Array.isArray(t)?t:[]).map(s=>typeof s=="object"&&s?s.original??null:null).filter(Boolean);return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-emerald-400/80 font-semibold text-sm",children:"Starting penetration test"}),a.length===1&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["on ",a[0]]})]}),a.length>1&&g.jsx("div",{className:"mt-1.5 space-y-0.5",children:a.map((s,o)=>g.jsxs("div",{className:"text-[13px] text-[#888]",children:[g.jsx("span",{className:"text-[#555] mr-1",children:"•"}),s]},o))})]})}function z7({args:e}){const t=e.name??"Unknown Agent",r=e.task??"";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-[#888] text-[13px]",children:"subagent"}),g.jsx("span",{className:"text-purple-400 font-semibold text-sm",children:t})]}),r&&g.jsx("div",{className:"mt-1.5",children:g.jsx(An,{text:r,maxLines:15})})]})}function I7(e){return e.toolName==="subagent_start_info"?g.jsx(z7,{...e}):g.jsx(L7,{...e})}function B7({args:e}){const t=e.executive_summary??"",r=e.methodology??"",a=e.technical_analysis??"",s=e.recommendations??"";return g.jsxs("div",{className:"space-y-3",children:[g.jsx("span",{className:"text-emerald-400/80 font-semibold text-sm",children:"Penetration test completed"}),t&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Executive Summary"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:t,maxLines:25})})]}),r&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Methodology"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:r,maxLines:25})})]}),a&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Technical Analysis"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:a,maxLines:25})})]}),s&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Recommendations"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:s,maxLines:25})})]}),!t&&!r&&!a&&!s&&g.jsx("div",{className:"text-[#555] text-xs",children:"Generating final report..."})]})}function U7({toolName:e,args:t,result:r}){if(e==="create_note"){const a=t.title??"",s=t.content??"",o=t.category??"general";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note"}),g.jsxs("span",{className:"text-[#555] text-[13px]",children:["(",o,")"]})]}),a&&g.jsx("div",{className:"mt-1.5 text-[#999] text-[13px]",children:a}),s&&g.jsx("div",{className:"mt-1",children:g.jsx(ua,{text:s})})]})}if(e==="delete_note")return g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note removed"});if(e==="update_note"){const a=t.title??"",s=t.content??"";return g.jsxs("div",{children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note updated"}),a&&g.jsx("div",{className:"mt-1.5 text-[#999] text-[13px]",children:a}),s&&g.jsx("div",{className:"mt-1",children:g.jsx(ua,{text:s})})]})}if(e==="get_note"){const a=r,s=a&&typeof a=="object"&&a.success?a.note:void 0;return g.jsxs("div",{children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note read"}),s&&g.jsxs(g.Fragment,{children:[g.jsxs("div",{className:"mt-1.5 text-[#999] text-[13px]",children:[s.title??"(untitled)",g.jsxs("span",{className:"text-[#555] ml-1",children:["(",s.category??"general",")"]}),(s.by_you||s.agent_name)&&g.jsxs("span",{className:"text-[#666] ml-1 text-xs",children:["by ",s.by_you?"you":s.agent_name]})]}),s.content&&g.jsx("div",{className:"mt-1",children:g.jsx(ua,{text:s.content})})]})]})}if(e==="list_notes"){const a=r;let s=[];if(a&&typeof a=="object"&&a.success){const o=a.notes;s=Array.isArray(o)?o:[]}return g.jsxs("div",{children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"notes"}),s.length>0?g.jsx("div",{className:"mt-1.5 space-y-0.5",children:s.map((o,c)=>g.jsxs("div",{className:"text-[13px]",children:[g.jsx("span",{className:"text-[#555] mr-1",children:"-"}),g.jsx("span",{className:"text-[#999]",children:o.title??"(untitled)"}),g.jsxs("span",{className:"text-[#555] ml-1",children:["(",o.category??"general",")"]}),(o.by_you||o.agent_name)&&g.jsxs("span",{className:"text-[#666] ml-1 text-xs",children:["by ",o.by_you?"you":o.agent_name]}),o.content&&g.jsx("div",{className:"ml-3",children:g.jsx(ua,{text:o.content})})]},c))}):g.jsx("div",{className:"mt-1 text-[#555] text-xs",children:"No notes"})]})}return g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note"})}const H7={create_todo:{label:"Task added",Icon:VC},list_todos:{label:"Plan",Icon:Gk},update_todo:{label:"Task updated",Icon:qC},mark_todo_done:{label:"Task completed",Icon:R_},mark_todo_pending:{label:"Task reopened",Icon:eT},delete_todo:{label:"Task removed",Icon:hT}};function $7({status:e}){return e==="done"?g.jsx(R_,{className:"w-3.5 h-3.5 text-emerald-400/70 shrink-0"}):e==="in_progress"?g.jsx(rC,{className:"w-3.5 h-3.5 text-purple-400/70 shrink-0 animate-pulse"}):g.jsx(aC,{className:"w-3.5 h-3.5 text-[#444] shrink-0"})}function q7({todos:e,highlightId:t}){return g.jsx("div",{className:"space-y-0",children:e.map((r,a)=>{const s=r.status??"pending",o=t&&r.id===t;return g.jsxs("div",{className:`flex items-start gap-2.5 py-1.5 px-2 -mx-2 rounded-md transition-colors ${o?"bg-purple-500/[0.08]":""}`,children:[g.jsx("div",{className:"mt-[1px]",children:g.jsx($7,{status:s})}),g.jsx("span",{className:`text-[13px] leading-snug ${s==="done"?"text-[#555] line-through":s==="in_progress"?"text-[#bbb]":"text-[#999]"}`,children:r.title??"(untitled)"})]},r.id??a)})})}function P7({toolName:e,args:t,result:r}){const a=H7[e]??{label:"Plan",Icon:ZC},s=a.Icon,o=r;if(typeof o=="string"&&o.trim())return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(s,{className:"w-3.5 h-3.5 text-purple-400/60"}),g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:a.label})]}),g.jsx("div",{className:"mt-1.5 text-[#888] text-[13px]",children:o.trim()})]});let c=[],d=null,h;if(o&&typeof o=="object"){if(d=o.error??null,o.success){const m=o.todos;c=Array.isArray(m)?m:[]}h=o.id??t.todo_id??void 0}const f=e!=="list_todos"?h:void 0;return c.length===0&&!d?g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(s,{className:"w-3.5 h-3.5 text-purple-400/60"}),g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:a.label})]}):g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2 mb-2",children:[g.jsx(s,{className:"w-3.5 h-3.5 text-purple-400/60"}),g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:a.label})]}),d&&g.jsx("div",{className:"text-red-400/70 text-[13px] mb-2",children:d}),c.length>0&&g.jsx("div",{className:"rounded-lg border border-white/[0.06] bg-white/[0.015] px-3 py-2",children:g.jsx(q7,{todos:c,highlightId:f})})]})}function d_(e){if(e==null)return null;if(typeof e=="string")return e.trim()?e:null;if(typeof e=="object"){const t=e;if(typeof t.__raw=="string")return t.__raw;if(Object.keys(t).length===0)return null;try{return JSON.stringify(e,null,2)}catch{return String(e)}}return String(e)}function nS({toolName:e,args:t,result:r}){const a=d_(t),s=d_(r);return g.jsxs("div",{children:[g.jsx("span",{className:"text-[#888] font-semibold text-sm",children:e.replace(/_/g," ")}),a&&g.jsx(wi,{className:"text-[#777]",children:a}),s&&g.jsx(wi,{className:"text-[#666]",children:s})]})}function F7({args:e}){const t=e.skills,r=(Array.isArray(t)?t:String(t??"").split(",")).map(a=>String(a).trim()).filter(Boolean);return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-emerald-400/80 font-semibold text-sm",children:"Loading skill"}),r.length>0&&g.jsx("span",{className:"text-[#888] text-[13px]",children:r.join(", ")})]})}function G7({args:e}){const t=e.message??"";return t?g.jsxs("div",{children:[g.jsx(ua,{text:t}),g.jsx("div",{className:"mt-1.5 text-[#888] text-[13px]",children:"waiting for your reply"})]}):null}const ad={terminal:{renderer:e7,icon:B_,color:"text-emerald-400"},python:{renderer:D7,icon:oC,color:"text-yellow-400"},browser:{renderer:n7,icon:z_,color:"text-blue-400"},filesystem:{renderer:r7,icon:gC,color:"text-sky-400"},proxy:{renderer:N7,icon:T_,color:"text-purple-400",match:/request|sitemap|scope/},reporting:{renderer:h7,icon:iT,color:"text-red-400"},thinking:{renderer:S7,icon:M_,color:"text-purple-400"},agents:{renderer:k7,icon:Ao,color:"text-cyan-400",match:/agent/},search:{renderer:C7,icon:nT,color:"text-amber-400"},lifecycle:{renderer:I7,icon:L_,color:"text-emerald-400"},notes:{renderer:U7,icon:uT,color:"text-amber-400",match:/note/},skills:{renderer:F7,icon:Hm,color:"text-emerald-400"},todos:{renderer:P7,icon:jC,color:"text-purple-400",match:/todo/},telemetry:{renderer:nS,icon:Hm,color:"text-[#555]"}},V7={terminal:["exec_command","write_stdin","terminal_execute"],python:["python_action"],browser:["browser_action"],filesystem:["apply_patch","view_image","str_replace_editor","list_files","search_files"],proxy:["list_requests","view_request","repeat_request","list_sitemap","view_sitemap_entry","scope_rules","send_request"],reporting:["create_vulnerability_report","list_reports","get_report"],thinking:["think"],agents:["create_agent","agent_finish","send_message_to_agent","wait_for_agents","view_agent_graph","stop_agent"],search:["web_search"],lifecycle:["scan_start_info","subagent_start_info","finish_scan","respond_to_user"],notes:["create_note","delete_note","update_note","list_notes","get_note"],skills:["load_skill"],todos:["create_todo","list_todos","update_todo","mark_todo_done","mark_todo_pending","delete_todo"],telemetry:["sandbox_error_details","llm_error_details"]},Y7=Object.fromEntries(Object.entries(V7).flatMap(([e,t])=>t.map(r=>[r,e]))),X7={finish_scan:B7,respond_to_user:G7,apply_patch:u7,view_image:d7,list_reports:l_,get_report:l_},K7={agent_finish:{icon:L_,color:"text-cyan-400"},send_message_to_agent:{icon:mh,color:"text-cyan-400"},wait_for_agents:{icon:mh,color:"text-cyan-400"},respond_to_user:{icon:mh,color:"text-emerald-400"},view_agent_graph:{icon:mC,color:"text-cyan-400"},stop_agent:{icon:A_,color:"text-red-400"},scan_start_info:{icon:dC,color:"text-emerald-400"},subagent_start_info:{icon:Ao,color:"text-purple-400"},view_image:{icon:AC,color:"text-sky-400"}},Z7=ad.telemetry;function rS(e){var r;const t=Y7[e];if(t)return t;for(const[a,s]of Object.entries(ad))if((r=s.match)!=null&&r.test(e))return a;return null}function Q7(e){const t=X7[e];if(t)return t;const r=rS(e);return r?ad[r].renderer:nS}function W7(e){const t=K7[e];if(t)return t;const r=rS(e),a=r?ad[r]:Z7;return{icon:a.icon,color:a.color}}const J7=30;function eU({role:e,content:t}){const r=e==="user"||e==="human";return g.jsxs("div",{children:[g.jsx("span",{className:`font-semibold text-sm ${r?"text-blue-400/80":"text-purple-400/80"}`,children:r?"User":"Thinking"}),g.jsx("div",{className:"mt-1.5 italic text-[#888]",children:g.jsx(An,{text:t,maxLines:J7})})]})}class tU extends ee.Component{constructor(t){super(t),this.state={hasError:!1}}static getDerivedStateFromError(){return{hasError:!0}}render(){return this.state.hasError?g.jsx("span",{className:"text-[#555] font-semibold text-sm",children:this.props.toolName.replace(/_/g," ")}):this.props.children}}function nU(e){const t=Q7(e.toolName);return g.jsx(tU,{toolName:e.toolName,children:g.jsx(t,{...e})})}function iS(e){if(e==null||typeof e!="string")return e;const t=e.trim();if(!t)return e;try{return JSON.parse(t)}catch{}try{const r=t.replace(/\bNone\b/g,"null").replace(/\bTrue\b/g,"true").replace(/\bFalse\b/g,"false").replace(/'/g,'"');return JSON.parse(r)}catch{return{__raw:e}}}function aS(e){const t=iS(e);return t&&typeof t=="object"&&!Array.isArray(t)?t:t==null?{}:{__raw:typeof t=="string"?t:JSON.stringify(t)}}function f_(e){const t=/(\d+)$/.exec(e);return t?parseInt(t[1],10):0}function mg(e){var r;const t=(r=e.data)==null?void 0:r.role;return e.type==="chat"&&(t==="user"||t==="human")}function rU(e){var t;return mg(e)&&String(((t=e.data)==null?void 0:t.content)??"").startsWith("[Message from ")}function iU(e){const t=new Set;let r=!1;for(const a of e)if(mg(a)){if(rU(a)){t.add(a.id);continue}r||(r=!0,t.add(a.id))}return t}const aU={completed:"text-emerald-400 border-emerald-500/30 bg-emerald-500/10",running:"text-blue-400 border-blue-500/30 bg-blue-500/10",waiting:"text-yellow-400 border-yellow-500/30 bg-yellow-500/10",stopped:"text-[#aaa] border-[#333] bg-[#1a1a1a]",crashed:"text-red-400 border-red-500/30 bg-red-500/10",failed:"text-red-400 border-red-500/30 bg-red-500/10"};function sU(e){return e==="completed"?"completed":e==="running"?"running":e==="failed"||e==="crashed"?"failed":e}function lU(e,t){var d;const r=new Map;for(const h of e)if(h.parent_id){const f=r.get(h.parent_id)??[];f.push(h.id),r.set(h.parent_id,f)}const a=new Map,s=new Map,o=new Map;for(const h of t)if(h.type==="tool"){if(a.set(h.agent_id,(a.get(h.agent_id)??0)+1),((d=h.data)==null?void 0:d.tool_name)==="create_agent"){const f=aS(h.data.args),m=f.name??f.agent_name??"",p=f.task??"";m&&p&&o.set(m,p)}}else mg(h)||s.set(h.agent_id,(s.get(h.agent_id)??0)+1);const c=new Map;for(const h of e)c.set(h.id,{id:h.id,name:h.name,task:o.get(h.name)??"",status:sU(h.status),parentId:h.parent_id,children:r.get(h.id)??[],createdAt:h.created_at,toolCount:a.get(h.id)??0,messageCount:s.get(h.id)??0});return c}function oU({agent:e,events:t,showHeader:r=!0}){const a=ee.useMemo(()=>{const c=t.filter(h=>h.agent_id===e.id).sort((h,f)=>f_(h.id)-f_(f.id)),d=iU(c);return c.filter(h=>!d.has(h.id))},[t,e.id]),s=a.filter(c=>c.type==="tool").length,o=a.length-s;return g.jsxs("div",{children:[r&&g.jsxs(g.Fragment,{children:[g.jsxs("div",{className:"flex items-center gap-2 flex-wrap mb-1",children:[g.jsx("span",{className:"text-base font-semibold text-white truncate",children:e.name}),g.jsx("span",{className:`flex-shrink-0 text-xs font-medium capitalize px-2 py-0.5 rounded-full border ${aU[e.status]??"text-[#aaa] border-[#333] bg-[#1a1a1a]"}`,children:e.status}),g.jsx("span",{className:"font-mono text-xs text-[#555]",children:e.id})]}),g.jsxs("p",{className:"text-xs text-[#666] mb-4",children:[o," message",o===1?"":"s"," · ",s," tool call",s===1?"":"s"]})]}),a.length===0?g.jsx("p",{className:"text-sm text-[#666]",children:"No recorded activity for this agent."}):g.jsx("div",{className:"py-1",children:a.map((c,d)=>{var N,S,w,k,E,M;const h=d===a.length-1,f=c.type==="tool",m=f?String(((N=c.data)==null?void 0:N.tool_name)??"tool"):"",p=f?"":String(((S=c.data)==null?void 0:S.role)??"assistant");let y,x;if(f){const I=W7(m);y=I.icon,x=I.color}else{const I=p==="user"||p==="human";y=I?Ao:M_,x=I?"text-blue-400":"text-purple-400"}const _=f?String(((w=c.data)==null?void 0:w.status)??"completed"):"completed";return g.jsxs("div",{className:"flex gap-3",children:[g.jsxs("div",{className:"flex flex-col items-center shrink-0",children:[g.jsx("div",{className:`w-[30px] h-[30px] rounded-full bg-black border flex items-center justify-center shrink-0 ${f&&_==="running"?"border-blue-500/40 animate-pulse":f&&_==="failed"?"border-red-500/30":"border-[#222]"}`,children:g.jsx(y,{className:`w-3.5 h-3.5 ${x}`})}),!h&&g.jsx("div",{className:"w-px flex-1 bg-[#1a1a1a] mt-1"})]}),g.jsx("div",{className:"flex-1 min-w-0 pt-[5px] pb-6",children:f?g.jsx(nU,{toolName:m,args:aS((k=c.data)==null?void 0:k.args),result:iS((E=c.data)==null?void 0:E.result)??null,status:_}):g.jsx(eU,{role:p,content:String(((M=c.data)==null?void 0:M.content)??"")})})]},c.id)})})]})}class Iu extends Error{constructor(t){super(t),this.name="RunParseError"}}const cU=["critical","high","medium","low"];function uU(e){const t=String(e??"").toLowerCase().trim();return cU.includes(t)?t:"low"}function dU(e){if(typeof e=="string"&&e.trim()){const t=e.trim().replace(" UTC","Z").replace(" ","T"),r=new Date(t);if(!Number.isNaN(r.getTime()))return r.toISOString();const a=new Date(e);if(!Number.isNaN(a.getTime()))return a.toISOString()}return new Date().toISOString()}function Ot(e){return typeof e=="string"&&e.length>0?e:null}function fU(e){return typeof e=="number"&&Number.isFinite(e)?e:null}function sS(e,t){try{return JSON.parse(e)}catch{throw new Iu(`${t} isn't valid JSON. Make sure you selected a Strix run directory.`)}}function hU(e){const t=sS(e,"run.json");if(!t||typeof t!="object"||Array.isArray(t))throw new Iu("run.json is not an object.");const r=t,a=[],s=r.targets_info;if(Array.isArray(s)){for(const x of s)if(x&&typeof x=="object"){const _=x.original;typeof _=="string"&&_&&a.push(_)}}const o=Ot(r.start_time),c=Ot(r.end_time);let d=null;if(o&&c){const x=new Date(o).getTime(),_=new Date(c).getTime();!Number.isNaN(x)&&!Number.isNaN(_)&&_>=x&&(d=Math.round((_-x)/1e3))}let h=null,f=null,m=null,p=null;const y=r.scan_results;if(y&&typeof y=="object"){const x=y;h=Ot(x.executive_summary),f=Ot(x.technical_analysis),m=Ot(x.methodology),p=Ot(x.recommendations)}return{runId:Ot(r.run_id),runName:Ot(r.run_name),targets:a,scanMode:Ot(r.scan_mode),status:Ot(r.status),startTime:o,endTime:c,durationSeconds:d,executiveSummary:h,technicalAnalysis:f,methodology:m,recommendations:p}}function mU(){return{pr_review_id:null,cve:null,cvss:null,potential_risk_saving:null,risk_saving_description:null,impact:null,endpoint:null,method:null,target:null,technical_analysis:null,poc_description:null,poc_script_code:null,code_diff:null,code_file:null,code_before:null,code_after:null,cwe:null,code_locations:null,remediation_steps:null,fix_pr_body:null,evidence:null,assumptions:null,fix_effort:null,cvss_breakdown:null,status_changed_at:null,status_changed_by:null,status_note:null,snoozed_until:null,reopened_at:null,reopened_by:null,original_severity:null,severity_changed_at:null,severity_changed_by:null,severity_override_reason:null,retest_of_vulnerability_id:null}}function pU(e,t,r){const a=e.cwe,s=typeof a=="string"&&a.trim()?[a.trim()]:Array.isArray(a)?a.filter(c=>typeof c=="string"&&c):null;return{...mU(),id:Ot(e.id)??`vuln-${t+1}`,scan_id:r,title:Ot(e.title)??"Untitled finding",description:Ot(e.description)??"",severity:uU(e.severity),status:"open",created_at:dU(e.timestamp),cve:Ot(e.cve),cvss:fU(e.cvss),impact:Ot(e.impact),endpoint:Ot(e.endpoint),method:Ot(e.method),target:Ot(e.target),technical_analysis:Ot(e.technical_analysis),poc_description:Ot(e.poc_description),poc_script_code:Ot(e.poc_script_code),cwe:s,code_locations:Array.isArray(e.code_locations)?e.code_locations:null,remediation_steps:Ot(e.remediation_steps),fix_pr_body:Ot(e.fix_pr_body),evidence:Ot(e.evidence),assumptions:Ot(e.assumptions),fix_effort:Ot(e.fix_effort)??null,cvss_breakdown:e.cvss_breakdown??null}}function gU(e,t=null){const r=sS(e,"vulnerabilities.json");if(!Array.isArray(r))throw new Iu("vulnerabilities.json is not a JSON array.");return r.map((a,s)=>{if(!a||typeof a!="object")throw new Iu(`vulnerabilities.json entry #${s+1} is not an object.`);return pU(a,s,t)})}function bU(e){const t={critical:0,high:0,medium:0,low:0};for(const r of e)t[r.severity]+=1;return t}async function Ja(e){const t=await fetch(e,{cache:"no-store"});if(!t.ok)throw new Error(`${e} responded ${t.status}`);return t.json()}function sd(e){return e?`?run=${encodeURIComponent(e)}`:""}async function lS(e){const t=await Ja("/api/run"+sd(e)),r=hU(JSON.stringify(t)),a=t.finished===!0;return{summary:r,raw:t,finished:a}}async function oS(e,t){const r=await Ja("/api/vulnerabilities"+sd(t));return gU(JSON.stringify(r),e)}async function xU(e){const t=await Ja("/api/report"+sd(e));return(t==null?void 0:t.markdown)??null}async function cS(e){const t=await Ja("/api/transcript"+sd(e));return{agents:Array.isArray(t==null?void 0:t.agents)?t.agents:[],events:Array.isArray(t==null?void 0:t.events)?t.events:[]}}async function h_(e){const{summary:t,raw:r,finished:a}=await lS(e),[s,o,c]=await Promise.all([oS(t.runId,e).catch(()=>[]),xU(e).catch(()=>null),cS(e).catch(()=>({agents:[],events:[]}))]);return{summary:t,raw:r,finished:a,vulnerabilities:s,reportMarkdown:o,transcript:c}}async function rl(e,t){const r=await fetch(e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t),cache:"no-store"});let a={};try{const s=await r.json();s&&typeof s=="object"&&(a=s)}catch{}return{ok:r.ok,status:r.status,data:a}}async function yU(){const e=await Ja("/api/runs");return{locked:(e==null?void 0:e.locked)??!0,count:typeof(e==null?void 0:e.count)=="number"?e.count:0,runs:Array.isArray(e==null?void 0:e.runs)?e.runs:[]}}async function vU(){const e=await Ja("/api/capabilities");return{can_steer:(e==null?void 0:e.can_steer)===!0}}async function _U(e,t){const{ok:r,data:a}=await rl("/api/agents/steer",{agent_id:e,message:t});return r&&a.ok===!0?{ok:!0}:{ok:!1,error:String(a.error??"unavailable")}}async function wU(e,t){const{ok:r,data:a}=await rl("/api/feedback",{message:e,email:t});return r&&a.ok===!0?{ok:!0}:{ok:!1,error:String(a.error??"unavailable")}}async function EU(){const e=await Ja("/api/auth/status");return{verified:(e==null?void 0:e.verified)===!0,email:(e==null?void 0:e.email)??null}}async function uS(e){const{ok:t,data:r}=await rl("/api/auth/otp/start",{email:e});return t&&r.ok===!0?{ok:!0}:{ok:!1,error:String(r.error??"unavailable")}}async function dS(e,t){const{ok:r,data:a}=await rl("/api/auth/otp/verify",{email:e,code:t});return r&&a.verified===!0?{verified:!0,email:String(a.email??e)}:{verified:!1,error:String(a.error??"invalid_code")}}async function NU(){await rl("/api/auth/forget",{})}async function SU(e){const{ok:t,data:r}=await rl("/api/report/send",e?{run:e}:{});return t&&r.ok===!0?{ok:!0,password:String(r.password??""),filename:String(r.filename??"strix-report.pdf")}:{ok:!1,error:String(r.error??"unavailable")}}const Bs="__root__";function fS({agents:e,fixedAgentId:t,className:r}){const a=ee.useRef(null),[s,o]=ee.useState(!1),[c,d]=ee.useState(!1),[h,f]=ee.useState(""),[m,p]=ee.useState(!1),[y,x]=ee.useState(null),_=t!=null,N=ee.useMemo(()=>e.find(z=>!z.parent_id)??e[0]??null,[e]),S=ee.useMemo(()=>e.filter(z=>z.parent_id&&z.status==="running"),[e]),[w,k]=ee.useState(Bs),[E,M]=ee.useState(!1);ee.useEffect(()=>{w!==Bs&&!S.some(z=>z.id===w)&&k(Bs)},[w,S]);const{targetId:I,targetName:R}=ee.useMemo(()=>{if(_){const V=e.find(P=>P.id===t)??null;return{targetId:t??null,targetName:(V==null?void 0:V.name)??"this agent"}}if(w===Bs)return{targetId:(N==null?void 0:N.id)??null,targetName:"Root agent"};const z=e.find(V=>V.id===w)??null;return{targetId:(z==null?void 0:z.id)??(N==null?void 0:N.id)??null,targetName:(z==null?void 0:z.name)??"Root agent"}},[e,t,_,N,w]),U=h.trim().length===0;ee.useLayoutEffect(()=>{const z=a.current;z&&(z.style.height="auto",z.style.height=`${z.scrollHeight}px`)},[h]);const B=ee.useCallback(()=>{o(!0),requestAnimationFrame(()=>{var z;return(z=a.current)==null?void 0:z.focus()})},[]),Z=ee.useCallback(()=>{o(!1),d(!1),M(!1)},[]),j=ee.useCallback(async()=>{if(m)return;const z=h.trim();if(!z||!I)return;p(!0),x(null);const V=R,P=await _U(I,z);p(!1),P.ok?(f(""),x(`Sent to ${V}`),Tr("agent_steered")):P.error==="not_delivered"?x("Could not reach that agent (it may have finished)."):x("Could not send that message. Try again.")},[m,h,I,R]);return s?g.jsxs("div",{className:Mr("mt-4 rounded-2xl border border-white/[0.08] bg-[#050505] overflow-hidden transition-colors duration-300",c?"border-white/[0.18]":"hover:border-white/[0.12]",r),children:[g.jsxs("div",{className:"flex items-center justify-between gap-3 border-b border-white/[0.06] px-5 py-3",children:[g.jsxs("div",{className:"min-w-0",children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(Um,{className:"h-4 w-4 text-[#666]"}),g.jsx("p",{className:"text-sm font-medium text-white",children:"Live prompt"})]}),g.jsx("p",{className:"mt-0.5 text-xs text-[#777]",children:"Connected"})]}),g.jsxs("div",{className:"flex shrink-0 items-center gap-2",children:[_?g.jsxs("div",{className:"rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1 text-xs text-[#aaa]",children:["Target: ",g.jsx("span",{className:"text-white",children:R})]}):g.jsxs("div",{className:"flex items-center gap-1.5",children:[g.jsx("span",{className:"text-xs text-[#aaa]",children:"Target:"}),g.jsxs("div",{className:"relative",children:[g.jsxs("button",{type:"button",onClick:()=>M(z=>!z),onBlur:()=>requestAnimationFrame(()=>M(!1)),className:"inline-flex h-7 items-center gap-1 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 text-xs text-white transition-colors hover:border-white/[0.16] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20","aria-haspopup":"listbox","aria-expanded":E,children:[g.jsx("span",{className:"max-w-[140px] truncate",children:R}),g.jsx(ho,{className:"h-3.5 w-3.5 text-[#999]"})]}),E&&g.jsxs("div",{className:"absolute right-0 z-10 mt-1 min-w-[160px] overflow-hidden rounded-lg border border-[#333] bg-[#0a0a0a] py-1 shadow-xl",role:"listbox",children:[g.jsx(m_,{label:"Root agent",active:w===Bs,onSelect:()=>{k(Bs),M(!1)}}),S.map(z=>g.jsx(m_,{label:z.name,active:w===z.id,onSelect:()=>{k(z.id),M(!1)}},z.id))]})]})]}),g.jsx("button",{type:"button",onClick:Z,className:"inline-flex h-7 w-7 items-center justify-center rounded-full text-[#777] transition-colors hover:bg-white/[0.06] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20","aria-label":"Collapse live prompt composer",children:g.jsx(ho,{className:"h-4 w-4"})})]})]}),g.jsx("div",{className:"px-5 pt-4 pb-3",children:g.jsx("textarea",{ref:a,rows:1,value:h,onChange:z=>f(z.target.value),onFocus:()=>d(!0),onBlur:()=>d(!1),onKeyDown:z=>{z.key==="Enter"&&!z.shiftKey&&(z.preventDefault(),j())},placeholder:"Send a live prompt to the running pentest…",maxLength:4e3,disabled:m,className:"block w-full resize-none border-0 bg-transparent p-0 text-[15px] leading-6 text-white placeholder:text-[#444] focus:outline-none disabled:opacity-60 max-h-[160px] overflow-y-auto"})}),g.jsxs("div",{className:"flex items-center justify-between gap-3 px-4 pb-4",children:[g.jsx("div",{className:"text-xs text-[#666]",children:y??"Press Enter to send."}),g.jsxs("button",{type:"button",onClick:z=>{z.stopPropagation(),j()},disabled:m||U,className:Mr("inline-flex h-10 min-w-[112px] items-center justify-center gap-2 rounded-full px-4 text-sm font-medium transition-colors",m||U?"bg-white/[0.08] text-[#666]":"bg-white text-black hover:bg-neutral-200"),children:[m?g.jsx(qs,{className:"h-4 w-4 animate-spin"}):g.jsx(zk,{className:"h-4 w-4",strokeWidth:2.5}),g.jsx("span",{children:"Send prompt"})]})]})]}):g.jsxs("button",{type:"button",onClick:B,className:Mr("mt-4 flex w-full items-center justify-between gap-3 rounded-2xl border border-white/[0.08] bg-[#050505] px-5 py-3 text-left transition-colors duration-300 hover:border-white/[0.12] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20",r),"aria-expanded":!1,"aria-label":"Expand live prompt composer",children:[g.jsxs("div",{className:"flex min-w-0 items-center gap-2",children:[g.jsx(Um,{className:"h-4 w-4 shrink-0 text-[#666]"}),g.jsx("span",{className:"truncate text-sm font-medium text-white",children:"Guide the agent"})]}),g.jsx(O_,{className:"h-4 w-4 shrink-0 text-[#777]"})]})}function m_({label:e,active:t,onSelect:r}){return g.jsx("button",{type:"button",role:"option","aria-selected":t,onMouseDown:a=>{a.preventDefault(),r()},className:Mr("block w-full truncate px-3 py-1.5 text-left text-xs transition-colors hover:bg-white/[0.06]",t?"text-white":"text-[#aaa]"),children:e})}const kU={completed:"bg-emerald-400",running:"bg-blue-400",waiting:"bg-yellow-400",stopped:"bg-[#888]",crashed:"bg-red-400",failed:"bg-red-400"},CU=80;function TU({open:e,agent:t,events:r,steerable:a,onClose:s}){const o=ee.useRef(null),c=ee.useRef(!1),[d,h]=ee.useState(e),[f,m]=ee.useState(e?"open":"closed"),[p,y]=ee.useState(!1),x=ee.useRef(t);ee.useEffect(()=>{t&&(x.current=t)},[t]);const _=t??x.current;ee.useEffect(()=>{if(e){h(!0),m("open");return}m("closed");const S=setTimeout(()=>h(!1),140);return()=>clearTimeout(S)},[e]),ee.useEffect(()=>{if(!d){y(!1);return}const S=requestAnimationFrame(()=>y(!0));return()=>cancelAnimationFrame(S)},[d]);const N=ee.useCallback(()=>{const S=o.current;S&&(c.current=S.scrollHeight-S.scrollTop-S.clientHeight{const S=o.current;!S||!c.current||requestAnimationFrame(()=>{S.scrollTo({top:S.scrollHeight,behavior:"smooth"})})},[r]),ee.useEffect(()=>{if(!d)return;const S=k=>{k.key==="Escape"&&s()};document.addEventListener("keydown",S);const w=document.body.style.overflow;return document.body.style.overflow="hidden",()=>{document.removeEventListener("keydown",S),document.body.style.overflow=w}},[d,s]),!d||!_?null:g.jsx("div",{"data-state":f,className:"agent-modal fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 sm:p-8",onClick:s,role:"dialog","aria-modal":"true","aria-label":`Agent ${_.name}`,children:g.jsxs("div",{className:"relative flex h-[60vh] w-[calc(100vw-4rem)] max-w-6xl flex-col overflow-hidden rounded-xl border border-[#222] bg-[#0a0a0a] shadow-2xl",onClick:S=>S.stopPropagation(),children:[g.jsxs("div",{className:"flex items-center justify-between gap-3 border-b border-[#222] px-5 py-3.5",children:[g.jsxs("div",{className:"flex min-w-0 items-center gap-2",children:[g.jsx("span",{className:`h-2 w-2 flex-shrink-0 rounded-full ${kU[_.status]??"bg-[#888]"}`}),g.jsx("span",{className:"truncate text-sm font-semibold text-white",children:_.name}),g.jsx("span",{className:"flex-shrink-0 font-mono text-xs text-[#555]",children:_.id})]}),g.jsx("button",{type:"button",onClick:s,"aria-label":"Close",className:"flex-shrink-0 rounded-md p-1 text-[#888] transition-colors hover:bg-[#1a1a1a] hover:text-white",children:g.jsx(vp,{className:"h-4 w-4"})})]}),g.jsx("div",{ref:o,onScroll:N,className:"flex-1 overflow-y-auto p-5",children:p&&g.jsx(oU,{agent:_,events:r,showHeader:!1})}),a&&g.jsx("div",{className:"border-t border-[#222] px-5 py-3",children:g.jsx(fS,{agents:[_],fixedAgentId:_.id,className:"mt-0"})})]})})}var hS={color:void 0,size:void 0,className:void 0,style:void 0,attr:void 0},p_=da.createContext&&da.createContext(hS),AU=["attr","size","title"];function MU(e,t){if(e==null)return{};var r,a,s=OU(e,t);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(e);for(a=0;ada.createElement(t.tag,Uu({key:r},t.attr),mS(t.child)))}function pg(e){return t=>da.createElement(LU,Bu({attr:Uu({},e.attr)},t),mS(e.child))}function LU(e){var t=r=>{var a=e.attr,s=e.size,o=e.title,c=MU(e,AU),d=s||r.size||"1em",h;return r.className&&(h=r.className),e.className&&(h=(h?h+" ":"")+e.className),da.createElement("svg",Bu({stroke:"currentColor",fill:"currentColor",strokeWidth:"0"},r.attr,a,c,{className:h,style:Uu(Uu({color:e.color||r.color},r.style),e.style),height:d,width:d,xmlns:"http://www.w3.org/2000/svg"}),o&&da.createElement("title",null,o),e.children)};return p_!==void 0?da.createElement(p_.Consumer,null,r=>t(r)):t(hS)}function zU(e){return pg({attr:{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round"},child:[{tag:"circle",attr:{cx:"5",cy:"6",r:"3"},child:[]},{tag:"path",attr:{d:"M5 9v12"},child:[]},{tag:"circle",attr:{cx:"19",cy:"18",r:"3"},child:[]},{tag:"path",attr:{d:"m15 9-3-3 3-3"},child:[]},{tag:"path",attr:{d:"M12 6h5a2 2 0 0 1 2 2v7"},child:[]}]})(e)}function IU(e){return pg({attr:{viewBox:"0 0 16 16",fill:"currentColor"},child:[{tag:"path",attr:{d:"M15 4.95703C15 4.58711 14.8563 4.24054 14.5949 3.97992L12.0096 1.39234C11.4879 0.86922 10.5788 0.86922 10.0571 1.39234L8 3.45119V3.32321C8 2.55068 7.37187 1.922 6.6 1.922H2.4C1.62813 1.922 1 2.55068 1 3.32321V13.5988C1 14.3713 1.62813 15 2.4 15H12.6667C13.4385 15 14.0667 14.3713 14.0667 13.5988V9.39514C14.0667 8.62261 13.4385 7.99393 12.6667 7.99393H12.5379L14.5949 5.93508C14.8553 5.67445 15 5.32602 15 4.95703ZM2.4 2.85521H6.6C6.85667 2.85521 7.06667 3.06446 7.06667 3.32228V7.99299H1.93333V3.32228C1.93333 3.06446 2.14333 2.85521 2.4 2.85521ZM1.93333 13.5979V8.92714H7.06667V14.0649H2.4C2.14333 14.0649 1.93333 13.8547 1.93333 13.5979ZM13.1333 9.39421V13.5979C13.1333 13.8547 12.9233 14.0649 12.6667 14.0649H8V8.92714H12.6667C12.9233 8.92714 13.1333 9.13638 13.1333 9.39421ZM8 7.99299V6.46287L9.5288 7.99299H8ZM13.9351 5.2737L11.3488 7.86221C11.1789 8.03223 10.8859 8.03223 10.716 7.86221L8.12973 5.2737C8.0448 5.18963 7.99813 5.07753 7.99813 4.95796C7.99813 4.83839 8.0448 4.7263 8.12973 4.64129L10.716 2.05278C10.8009 1.96777 10.9129 1.92106 11.0324 1.92106C11.1519 1.92106 11.2639 1.96777 11.3488 2.05278L13.9351 4.64129C14.02 4.72536 14.0667 4.83746 14.0667 4.95703C14.0667 5.0766 14.02 5.1887 13.9351 5.2737Z"},child:[]}]})(e)}function pS(e){return pg({attr:{viewBox:"0 0 512 512"},child:[{tag:"path",attr:{fill:"none",strokeLinecap:"round",strokeMiterlimit:"10",strokeWidth:"32",d:"M431 320.6c-1-3.6 1.2-8.6 3.3-12.2a34 34 0 0 1 2.1-3.1A162 162 0 0 0 464 215c.3-92.2-77.5-167-173.7-167-83.9 0-153.9 57.1-170.3 132.9a160.7 160.7 0 0 0-3.7 34.2c0 92.3 74.8 169.1 171 169.1 15.3 0 35.9-4.6 47.2-7.7s22.5-7.2 25.4-8.3a26.4 26.4 0 0 1 9.3-1.7 26 26 0 0 1 10.1 2l56.7 20.1a13.5 13.5 0 0 0 3.9 1 8 8 0 0 0 8-8 13 13 0 0 0-.5-2.7z"},child:[]},{tag:"path",attr:{fill:"none",strokeLinecap:"round",strokeMiterlimit:"10",strokeWidth:"32",d:"M66.46 232a146.23 146.23 0 0 0 6.39 152.67c2.31 3.49 3.61 6.19 3.21 8s-11.93 61.87-11.93 61.87a8 8 0 0 0 2.71 7.68A8.17 8.17 0 0 0 72 464a7.3 7.3 0 0 0 2.91-.6l56.21-22a15.7 15.7 0 0 1 12 .2c18.94 7.38 39.88 12 60.83 12A159.2 159.2 0 0 0 284 432.11"},child:[]}]})(e)}const BU=[{icon:_C,label:"PR security reviews"},{icon:lT,label:"Attack surface monitoring"},{icon:ET,label:"Real-time threat intelligence"},{icon:Pk,label:"Scheduled pentesting"},{icon:yT,label:"One-click autofix"},{icon:FC,label:"Jira, Linear & Slack integrations"}];function UU({open:e,onClose:t,description:r,source:a="sidebar"}){const[s,o]=ee.useState(e),[c,d]=ee.useState(e?"open":"closed");return ee.useEffect(()=>{if(e){o(!0),d("open");return}d("closed");const h=setTimeout(()=>o(!1),200);return()=>clearTimeout(h)},[e]),ee.useEffect(()=>{if(!s)return;const h=m=>{m.key==="Escape"&&t()};document.addEventListener("keydown",h);const f=document.body.style.overflow;return document.body.style.overflow="hidden",()=>{document.removeEventListener("keydown",h),document.body.style.overflow=f}},[s,t]),s?g.jsx("div",{"data-state":c,className:"dialog-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4",onClick:t,role:"dialog","aria-modal":"true","aria-label":"Upgrade your plan",children:g.jsxs("div",{"data-state":c,className:"dialog-panel relative w-full max-w-md rounded-2xl border border-[#222] bg-black p-6 shadow-lg sm:rounded-lg",onClick:h=>h.stopPropagation(),children:[g.jsx("button",{type:"button",onClick:t,"aria-label":"Close",className:"absolute right-4 top-4 rounded-md p-1 text-[#888] transition-colors hover:bg-[#1a1a1a] hover:text-white",children:g.jsx(vp,{className:"h-4 w-4"})}),g.jsxs("div",{children:[g.jsx("h2",{className:"text-lg text-white",children:"Available in Strix Cloud"}),r&&g.jsx("p",{className:"mt-2 text-base leading-relaxed text-[#e5e5e5]",children:r})]}),g.jsxs("div",{className:"space-y-4 pt-4",children:[g.jsxs("div",{className:"rounded-xl border border-[#333] bg-[#0a0a0a] p-4 sm:rounded-lg",children:[g.jsxs("div",{className:"mb-3 flex items-center gap-2",children:[g.jsx(Um,{className:"h-4 w-4 text-blue-400"}),g.jsx("span",{className:"text-sm font-medium text-white",children:"Strix Cloud also includes"})]}),g.jsx("ul",{className:"space-y-2 text-sm text-[#888]",children:BU.map(h=>g.jsxs("li",{className:"flex items-center gap-2",children:[g.jsx(h.icon,{className:"h-3.5 w-3.5 text-[#555]"}),h.label]},h.label))})]}),g.jsxs("div",{className:"flex flex-col gap-2",children:[g.jsxs("a",{href:ha($u,"upgrade_try_free"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("upgrade_try_free",a),className:"flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-white px-4 text-sm font-semibold text-black transition-colors hover:bg-neutral-200",children:["Open Strix Cloud",g.jsx(ry,{className:"h-3.5 w-3.5"})]}),g.jsxs("a",{href:ha(TT,"upgrade_view_plans"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("upgrade_view_plans",a),className:"flex h-9 w-full items-center justify-center gap-1.5 rounded-lg border border-[#333] px-4 text-sm font-medium text-[#888] transition-colors hover:border-[#555] hover:text-white",children:["Learn more",g.jsx(ry,{className:"h-3 w-3"})]})]})]})]})}):null}const Lm=160,zm=260,ro=400,HU=140,b_="strix_viewer_sidebar_width",x_="strix_viewer_sidebar_collapsed";function $U(e,t){try{const r=localStorage.getItem(e),a=r?parseInt(r,10):NaN;return Number.isFinite(a)?a:t}catch{return t}}function qU({view:e,onSelectView:t,issuesCount:r,agentCount:a,runCount:s,finished:o,verified:c,email:d,onOpenEmail:h,onOpenHistory:f,onForget:m}){var z;const[p,y]=ee.useState(()=>{const V=$U(b_,zm);return Math.min(ro,Math.max(Lm,V))}),[x,_]=ee.useState(()=>{try{return localStorage.getItem(x_)==="1"}catch{return!1}}),[N,S]=ee.useState(!1),[w,k]=ee.useState(!1),[E,M]=ee.useState(null),I=ee.useRef(null),R=(V,P)=>{jr(V,"sidebar"),M(P)},U=ee.useCallback(V=>{y(V);try{localStorage.setItem(b_,String(V))}catch{}},[]),B=ee.useCallback(V=>{_(V);try{localStorage.setItem(x_,V?"1":"0")}catch{}},[]),Z=ee.useCallback(()=>{B(!1),U(zm)},[B,U]),j=ee.useCallback(V=>{V.preventDefault(),S(!0)},[]);return ee.useEffect(()=>{if(!N||x)return;const V=T=>{const $=T.clientX;$>=Lm&&$<=ro?y($):$>ro&&y(ro)},P=T=>{const $=T.clientX;${window.removeEventListener("mousemove",V),window.removeEventListener("mouseup",P)}},[N,x,B,U]),ee.useEffect(()=>{if(!w)return;const V=P=>{I.current&&!I.current.contains(P.target)&&k(!1)};return document.addEventListener("mousedown",V),()=>document.removeEventListener("mousedown",V)},[w]),g.jsxs(g.Fragment,{children:[x&&g.jsx("div",{className:"fixed left-0 top-0 z-40 hidden h-full w-4 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.08)] lg:block",onClick:Z,title:"Expand sidebar"}),g.jsxs("aside",{className:Mr("sticky top-0 z-20 hidden h-screen flex-shrink-0 flex-col overflow-hidden border-r border-[rgba(255,255,255,0.08)] bg-black lg:flex",!N&&"transition-[width] duration-200 ease-out"),style:{width:x?0:p},children:[g.jsx("header",{className:"relative flex flex-col gap-1 pt-1 min-w-[160px]",children:g.jsx("div",{className:"flex flex-row py-1 px-2",children:g.jsxs("div",{className:"flex h-10 w-full flex-row items-center",children:[g.jsxs("a",{href:ha("https://app.strix.ai","logo"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("logo","sidebar"),className:"flex flex-1 flex-row items-center gap-2 rounded-md py-2 pl-2.5 pr-1 min-w-0 transition-colors hover:bg-[rgba(255,255,255,0.06)]",title:"Open Strix Cloud",children:[g.jsx("span",{className:"flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500",style:{width:20,height:20},children:g.jsx("span",{className:"text-[10px] font-semibold text-white",children:"S"})}),g.jsxs("span",{className:"flex flex-1 flex-row items-center gap-2 min-w-0",children:[g.jsx("span",{className:"truncate min-w-0 text-[14px] font-medium text-[#ededed]",children:"Strix"}),g.jsx("span",{className:"flex h-5 flex-shrink-0 items-center rounded px-2 text-[11px] font-medium text-[#888] bg-[rgba(255,255,255,0.08)]",children:"Local"})]})]}),g.jsx("a",{href:ha("https://app.strix.ai","logo"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("logo","sidebar"),className:"flex flex-none items-center rounded-md px-1.5 py-2 transition-colors hover:bg-[rgba(255,255,255,0.06)]","aria-label":"Open Strix Cloud",children:g.jsx(Wk,{className:"h-4 w-4 text-[#666]"})})]})})}),g.jsx("nav",{className:"relative min-w-[160px] flex-1 overflow-y-auto overflow-x-clip scrollbar-thin pb-10 pt-2",children:g.jsxs("div",{className:"relative flex flex-col gap-px px-2",children:[g.jsx(yi,{icon:g.jsx(PU,{}),label:"Pentest Overview",active:e==="overview",onClick:()=>t("overview")}),g.jsx(yi,{icon:g.jsx(pT,{className:"h-4 w-4"}),label:"Issues",count:r>0?r:void 0,active:e==="issues",onClick:()=>t("issues")}),a>0&&g.jsx(yi,{icon:g.jsx(Ao,{className:"h-4 w-4"}),label:"Agents",count:a,active:e==="agents",onClick:()=>t("agents")}),g.jsx(yi,{icon:g.jsx(Vs,{className:"h-4 w-4"}),label:"Past runs",count:s>0?s:void 0,active:e==="history",onClick:f}),o&&g.jsx(yi,{icon:g.jsx(yp,{className:"h-4 w-4"}),label:"Export report",active:e==="email",onClick:h}),g.jsx(yi,{icon:g.jsx(pS,{className:"h-4 w-4"}),label:"Feedback & support",active:e==="feedback",onClick:()=>t("feedback")}),g.jsx("hr",{className:"mx-0 my-1 h-px w-full border-0 bg-[rgba(255,255,255,0.08)]"}),g.jsx(yi,{icon:g.jsx(zU,{className:"h-4 w-4"}),label:"PR Security Reviews",active:!1,onClick:()=>R("pr_reviews","Strix reviews every pull request and flags exploitable changes before they merge.")}),g.jsx(yi,{icon:g.jsx(IU,{className:"h-4 w-4"}),label:"Integrations",active:!1,onClick:()=>R("integrations","Sync findings to Jira, Linear, and Slack so fixes happen where your team already works.")}),g.jsx(yi,{icon:g.jsx(bT,{className:"h-4 w-4"}),label:"Members",active:!1,onClick:()=>R("members","Invite your team, set roles, and share findings and run history across your org.")})]})}),g.jsx("section",{className:"flex min-w-[160px] flex-col gap-0.5",ref:I,children:g.jsxs("div",{className:"relative p-2",children:[c&&d?g.jsxs("button",{onClick:()=>k(V=>!V),className:"relative flex w-full cursor-pointer items-center gap-2 rounded-md bg-transparent px-2.5 py-2 transition-colors hover:bg-[rgba(255,255,255,0.06)]",children:[g.jsx("span",{className:"flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500",style:{width:20,height:20},children:g.jsx("span",{className:"text-[9px] font-semibold text-white",children:((z=d[0])==null?void 0:z.toUpperCase())||"U"})}),g.jsxs("span",{className:"flex min-w-0 flex-1 flex-col text-left",children:[g.jsx("span",{className:"truncate text-[13px] font-medium text-[#ededed]",children:d}),g.jsx("span",{className:"truncate text-[11px] text-[#555]",children:"Linked to this machine"})]})]}):g.jsxs("div",{className:"flex items-center gap-2 rounded-md px-2.5 py-2",children:[g.jsx("span",{className:"flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500",style:{width:20,height:20},children:g.jsx("span",{className:"text-[9px] font-semibold text-white",children:"S"})}),g.jsx("span",{className:"flex min-w-0 flex-1 flex-col text-left",children:g.jsx("span",{className:"truncate text-[13px] font-medium text-[#ededed]",children:"Local viewer"})})]}),w&&c&&d&&g.jsxs("div",{className:"absolute bottom-full left-2 right-2 z-50 mb-1 overflow-hidden rounded-lg border border-[#333] bg-black shadow-xl",children:[g.jsxs("div",{className:"border-b border-[#333] px-3 py-2",children:[g.jsx("p",{className:"truncate text-[13px] font-medium text-white",children:"Linked email"}),g.jsx("p",{className:"truncate text-[11px] text-[#666]",children:d})]}),g.jsxs("button",{onClick:()=>{k(!1),m()},className:"flex w-full items-center gap-2 px-3 py-2 text-[13px] text-[#888] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-red-400",children:[g.jsx(BC,{className:"h-4 w-4"}),"Forget this email"]})]})]})}),g.jsx("div",{className:"group absolute right-0 top-0 z-30 h-full w-1 cursor-col-resize",onMouseDown:j,children:g.jsx("div",{className:Mr("absolute right-0 top-0 h-full w-px bg-[rgba(255,255,255,0.08)] transition-all duration-100",N?"w-0.5 bg-[rgba(255,255,255,0.3)]":"group-hover:bg-[rgba(255,255,255,0.2)]")})})]}),N&&g.jsx("div",{className:"fixed inset-0 z-10 cursor-col-resize"}),g.jsx(UU,{open:E!==null,description:E??"",source:"sidebar",onClose:()=>M(null)})]})}function yi({icon:e,label:t,active:r,onClick:a,count:s}){return g.jsxs("button",{onClick:a,className:Mr("group flex h-9 w-full origin-left flex-row items-center rounded-md transition-colors",r?"bg-[rgba(255,255,255,0.12)] text-white":"text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-[#ededed]"),children:[g.jsx("div",{className:"grid flex-none place-content-center",style:{width:36,height:36},children:e}),g.jsx("span",{className:"min-w-0 flex-1 truncate text-left text-[14px] font-medium",children:t}),s!=null&&g.jsx("span",{className:"mr-2 flex-none rounded-full border border-white/10 px-2 py-0.5 text-[10px] tabular-nums leading-none text-[#777]",children:s})]})}function PU(){return g.jsx("svg",{style:{width:16,height:16,color:"currentcolor"},viewBox:"0 0 16 16",fill:"currentColor",children:g.jsx("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M2.5 5.5V2.5H5.5V5.5H2.5ZM1 2C1 1.44772 1.44772 1 2 1H6C6.55228 1 7 1.44772 7 2V6C7 6.55228 6.55228 7 6 7H2C1.44772 7 1 6.55228 1 6V2ZM2.5 13.5V10.5H5.5V13.5H2.5ZM1 10C1 9.44772 1.44772 9 2 9H6C6.55228 9 7 9.44772 7 10V14C7 14.5523 6.55228 15 6 15H2C1.44772 15 1 14.5523 1 14V10ZM10.5 2.5V5.5H13.5V2.5H10.5ZM10 1C9.44772 1 9 1.44772 9 2V6C9 6.55228 9.44772 7 10 7H14C14.5523 7 15 6.55228 15 6V2C15 1.44772 14.5523 1 14 1H10ZM10.5 13.5V10.5H13.5V13.5H10.5ZM9 10C9 9.44772 9.44772 9 10 9H14C14.5523 9 15 9.44772 15 10V14C15 14.5523 14.5523 15 14 15H10C9.44772 15 9 14.5523 9 14V10Z"})})}const y_={work_email_required:"Please use your work email, not a personal one.",rate_limited:"Too many requests. Wait a minute and try again.",invalid_email:"That email does not look right. Check it and try again.",unavailable:"The email service is unavailable right now. Try again shortly."},FU=new Set(["gmail.com","googlemail.com","yahoo.com","ymail.com","outlook.com","hotmail.com","live.com","icloud.com","me.com","aol.com","proton.me","protonmail.com","gmx.com","mail.com"]);function GU({onVerified:e}){const[t,r]=ee.useState("email"),[a,s]=ee.useState(""),[o,c]=ee.useState(""),[d,h]=ee.useState(!1),[f,m]=ee.useState(null),[p,y]=ee.useState(null),x=async()=>{const N=a.trim();if(!N){m("Enter your email to continue.");return}const S=N.slice(N.lastIndexOf("@")+1).toLowerCase();if(FU.has(S)){Tr("work_email_required"),m(y_.work_email_required);return}h(!0),m(null);const w=await uS(N);h(!1),w.ok?(Tr("email_submitted",{purpose:"verify"}),y(`We sent a 6-digit code to ${N}.`),r("code")):(w.error==="work_email_required"&&Tr("work_email_required"),m(y_[w.error]??"Could not send a code. Try again."))},_=async()=>{const N=o.trim();if(N.length<4){m("Enter the 6-digit code from your email.");return}h(!0),m(null);const S=await dS(a.trim(),N);if(h(!1),!S.verified){m("That code did not match. Check it and try again.");return}Tr("email_verified",{purpose:"verify"}),e()};return g.jsxs("div",{className:"mx-auto mt-5 max-w-sm text-left",children:[f&&g.jsxs("div",{className:"mb-3 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2",children:[g.jsx(Hu,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-red-400","aria-hidden":"true"}),g.jsx("p",{className:"text-xs text-red-300",children:f})]}),p&&!f&&g.jsx("p",{className:"mb-3 text-xs text-[#888]",children:p}),t==="email"?g.jsxs("form",{className:"space-y-3",onSubmit:N=>{N.preventDefault(),x()},children:[g.jsxs("label",{className:"block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"Your work email"}),g.jsx("input",{type:"email",autoFocus:!0,value:a,onChange:N=>s(N.target.value),placeholder:"you@company.com",className:"w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]",style:{border:"1px solid #2a2a2a"}}),g.jsx("span",{className:"mt-1.5 block text-[11px] text-[#666]",children:"Use your work email."})]}),g.jsxs("button",{type:"submit",disabled:d,className:"flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60",children:[d&&g.jsx(qs,{className:"h-4 w-4 animate-spin","aria-hidden":"true"}),"Send me a code"]})]}):g.jsxs("form",{className:"space-y-3",onSubmit:N=>{N.preventDefault(),_()},children:[g.jsxs("label",{className:"block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"6-digit code"}),g.jsx("input",{inputMode:"numeric",autoFocus:!0,value:o,onChange:N=>c(N.target.value.replace(/\D/g,"").slice(0,6)),placeholder:"123456",className:"w-full rounded-lg bg-black px-3 py-2.5 text-center text-lg font-mono tracking-[0.4em] text-white outline-none transition-colors focus:border-[#444]",style:{border:"1px solid #2a2a2a"}})]}),g.jsxs("button",{type:"submit",disabled:d,className:"flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60",children:[d&&g.jsx(qs,{className:"h-4 w-4 animate-spin","aria-hidden":"true"}),"Verify"]}),g.jsx("button",{type:"button",onClick:()=>{r("email"),m(null),y(null)},className:"w-full cursor-pointer text-center text-xs text-[#666] transition-colors hover:text-[#aaa]",children:"Use a different email"})]})]})}const VU=[{key:"critical",dot:"bg-red-500",text:"text-red-500"},{key:"high",dot:"bg-orange-500",text:"text-orange-500"},{key:"medium",dot:"bg-yellow-500",text:"text-yellow-500"},{key:"low",dot:"bg-blue-500",text:"text-blue-500"}];function YU({counts:e}){const t=VU.filter(r=>e[r.key]>0);return t.length===0?g.jsx("span",{className:"text-xs text-[#555]",children:"No findings"}):g.jsx("div",{className:"flex items-center gap-3",children:t.map(r=>g.jsxs("div",{className:"flex items-center gap-1.5",children:[g.jsx("span",{className:`h-2 w-2 rounded-full ${r.dot}`,"aria-hidden":"true"}),g.jsx("span",{className:`text-xs tabular-nums ${r.text}`,children:e[r.key]})]},r.key))})}function XU(e){if(!e)return null;const t=e.trim().replace(" UTC","Z").replace(" ","T"),r=new Date(t);return Number.isNaN(r.getTime())?null:r.toLocaleString(void 0,{month:"short",day:"numeric",hour:"numeric",minute:"2-digit"})}function v_(e){if(!e)return null;const t=e.trim().replace(" UTC","Z").replace(" ","T"),r=new Date(t);if(Number.isNaN(r.getTime()))return null;const a=Date.now()-r.getTime(),s=Math.floor(a/6e4);if(s<1)return"just now";if(s<60)return`${s}m ago`;const o=Math.floor(s/60);if(o<24)return`${o}h ago`;const c=Math.floor(o/24);return c<7?`${c}d ago`:XU(e)}function KU({runs:e,activeRun:t,onSelectRun:r,onVerified:a}){const s=(e==null?void 0:e.count)??0,[o,c]=ee.useState(!1);return!e||e.locked?g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center",children:[g.jsx("div",{className:"mx-auto mb-4 flex h-11 w-11 items-center justify-center rounded-xl",style:{border:"1px solid #2a2a2a",background:"rgba(255,255,255,0.04)"},children:g.jsx(Vs,{className:"h-5 w-5 text-[#888]","aria-hidden":"true"})}),g.jsx("h2",{className:"text-base font-semibold text-white",children:"Browse every run on this machine"}),g.jsxs("p",{className:"mx-auto mt-1.5 max-w-md text-sm text-[#888]",children:["You have ",s," past ",s===1?"run":"runs"," on this machine."]}),o?g.jsxs(g.Fragment,{children:[g.jsx("p",{className:"mx-auto mt-3 max-w-sm text-xs text-[#666]",children:"Verify your email with a one-time code to unlock the full history."}),g.jsx(GU,{onVerified:a})]}):g.jsx("button",{onClick:()=>{jr("history_unlock","past_runs"),c(!0)},className:"mt-4 cursor-pointer rounded-lg bg-white px-4 py-2 text-sm font-semibold text-black transition-opacity hover:opacity-90",children:"View runs"}),g.jsxs("p",{className:"mt-4 flex items-center justify-center gap-1.5 text-xs text-[#555]",children:[g.jsx(B_,{className:"h-3.5 w-3.5","aria-hidden":"true"}),"Or open one from the CLI with"," ",g.jsx("code",{className:"font-mono text-[#888]",children:"strix view "})]})]}):e.runs.length===0?g.jsx("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]",children:"No past runs found on this machine yet."}):g.jsx("div",{className:"space-y-2",children:e.runs.map(d=>{const h=d.name===t,f=v_(d.start_time)??v_(d.end_time),m=bo(d.target,d.name);return g.jsxs("button",{onClick:()=>r(d.name),className:`animate-card-in group flex w-full cursor-pointer items-center gap-4 rounded-lg border px-4 py-3 text-left transition-colors ${h?"border-[#444] bg-[rgba(255,255,255,0.04)]":"border-[#222] bg-[rgba(255,255,255,0.02)] hover:border-[#444]"}`,children:[g.jsxs("div",{className:"min-w-0 flex-1",children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"truncate text-sm font-medium text-white",children:m}),h&&g.jsx("span",{className:"rounded-full px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-400",style:{border:"1px solid rgba(16,185,129,0.3)"},children:"Active"})]}),g.jsxs("div",{className:"mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-[#666]",children:[d.scan_mode&&g.jsx("span",{className:"capitalize",children:d.scan_mode}),d.scan_mode&&(f||d.status)&&g.jsx("span",{className:"text-[#333]",children:"·"}),f&&g.jsx("span",{children:f}),f&&d.status&&g.jsx("span",{className:"text-[#333]",children:"·"}),d.status&&g.jsx("span",{className:"capitalize",children:d.status})]})]}),g.jsx(YU,{counts:d.severity_counts}),g.jsx(Kk,{className:"h-4 w-4 flex-shrink-0 text-[#555] transition-colors group-hover:text-[#aaa]","aria-hidden":"true"})]},d.name)})})}const __={work_email_required:"Please use your work email, not a personal one.",rate_limited:"Too many requests. Wait a minute and try again.",invalid_email:"That email does not look right. Check it and try again.",unavailable:"The email service is unavailable right now. Try again shortly."},ZU={forbidden:"This email was unsubscribed from Strix, so we cannot send to it.",too_large:"This report is too large to email. Try a smaller run.",unavailable:"The email service is unavailable right now. Try again shortly."},QU=new Set(["gmail.com","googlemail.com","yahoo.com","ymail.com","outlook.com","hotmail.com","live.com","icloud.com","me.com","aol.com","proton.me","protonmail.com","gmx.com","mail.com"]);function WU({activeRun:e,auth:t,purpose:r,skipDisclosure:a=!1,onAuthChanged:s,onExit:o}){const c=(t==null?void 0:t.verified)===!0,d=r==="verify",[h,f]=ee.useState(()=>d?"email":a?c?"sending":"email":"disclosure"),[m,p]=ee.useState((t==null?void 0:t.email)??""),[y,x]=ee.useState(""),[_,N]=ee.useState(!1),[S,w]=ee.useState(null),[k,E]=ee.useState(null),[M,I]=ee.useState(""),[R,U]=ee.useState(""),[B,Z]=ee.useState(!1),[j,z]=ee.useState(""),V=ee.useRef(!1),P=async()=>{f("sending"),w(null);const K=await SU(e);if(K.ok){Tr("report_sent"),I(K.password),U(K.filename),f("password");return}if(K.error==="reverify"||K.error==="unverified"){E("Your verification expired. Enter your email to verify again."),f("email");return}w(ZU[K.error]??"Could not send the report. Try again."),f("disclosure")},T=()=>{w(null),E(null),c?P():f("email")};ee.useEffect(()=>{!d&&a&&c&&!V.current&&(V.current=!0,P())},[]);const $=async()=>{const K=m.trim();if(!K){w("Enter your email to continue.");return}const C=K.slice(K.lastIndexOf("@")+1).toLowerCase();if(QU.has(C)){Tr("work_email_required"),w(__.work_email_required);return}N(!0),w(null);const D=await uS(K);N(!1),D.ok?(Tr("email_submitted",{purpose:r}),E(`We sent a 6-digit code to ${K}.`),f("code")):(D.error==="work_email_required"&&Tr("work_email_required"),w(__[D.error]??"Could not send a code. Try again."))},O=async()=>{const K=y.trim();if(K.length<4){w("Enter the 6-digit code from your email.");return}N(!0),w(null);const C=await dS(m.trim(),K);if(N(!1),!C.verified){w("That code did not match. Check it and try again.");return}Tr("email_verified",{purpose:r}),z(C.email),s(),d?o("history"):P()},H=async()=>{try{await navigator.clipboard.writeText(M),Z(!0),setTimeout(()=>Z(!1),1500)}catch{}},X=j||(t==null?void 0:t.email)||m.trim();return g.jsxs("div",{className:"mx-auto max-w-xl space-y-4",children:[g.jsxs("button",{onClick:()=>o(d?"history":"overview"),className:"cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white",children:[g.jsx(xp,{className:"h-4 w-4"}),d?"Back to past runs":"Back to results"]}),g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(yp,{className:"h-5 w-5 text-[#888]","aria-hidden":"true"}),g.jsx("h1",{className:"text-2xl font-semibold text-white",children:d?"Verify your email":"Export report to PDF"})]}),g.jsxs("div",{className:"w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6",style:{border:"1px solid #2a2a2a"},children:[g.jsx("p",{className:"mb-4 text-xs text-[#666]",children:d?"We send a one-time code to confirm it is you.":"Verified by a one-time code sent to your email"}),S&&g.jsxs("div",{className:"mb-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2",children:[g.jsx(Hu,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-red-400","aria-hidden":"true"}),g.jsx("p",{className:"text-xs text-red-300",children:S})]}),k&&!S&&h!=="password"&&g.jsx("p",{className:"mb-4 text-xs text-[#888]",children:k}),h==="disclosure"&&g.jsxs("div",{className:"space-y-4",children:[g.jsxs("div",{className:"space-y-2.5 rounded-lg p-3.5",style:{border:"1px solid #222",background:"rgba(255,255,255,0.02)"},children:[g.jsxs("div",{className:"flex items-start gap-2.5",children:[g.jsx(I_,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400","aria-hidden":"true"}),g.jsxs("p",{className:"text-xs leading-relaxed text-[#aaa]",children:["We email an ",g.jsx("span",{className:"text-white",children:"encrypted PDF"}),". Nothing else leaves your machine."]})]}),g.jsxs("div",{className:"flex items-start gap-2.5",children:[g.jsx(zC,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-[#888]","aria-hidden":"true"}),g.jsx("p",{className:"text-xs leading-relaxed text-[#aaa]",children:"Only you hold the password; Strix can't read it."})]})]}),g.jsx("button",{onClick:T,className:"w-full cursor-pointer rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90",children:"Export report"}),c&&(t==null?void 0:t.email)&&g.jsxs("p",{className:"text-center text-xs text-[#666]",children:["Sending to ",t.email]})]}),h==="email"&&g.jsxs("form",{className:"space-y-4",onSubmit:K=>{K.preventDefault(),$()},children:[g.jsxs("label",{className:"block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"Your work email"}),g.jsx("input",{type:"email",autoFocus:!0,value:m,onChange:K=>p(K.target.value),placeholder:"you@company.com",className:"w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]",style:{border:"1px solid #2a2a2a"}})]}),g.jsxs("button",{type:"submit",disabled:_,className:"flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60",children:[_&&g.jsx(qs,{className:"h-4 w-4 animate-spin","aria-hidden":"true"}),"Send me a code"]})]}),h==="code"&&g.jsxs("form",{className:"space-y-4",onSubmit:K=>{K.preventDefault(),O()},children:[g.jsxs("label",{className:"block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"6-digit code"}),g.jsx("input",{inputMode:"numeric",autoFocus:!0,value:y,onChange:K=>x(K.target.value.replace(/\D/g,"").slice(0,6)),placeholder:"123456",className:"w-full rounded-lg bg-black px-3 py-2.5 text-center text-lg font-mono tracking-[0.4em] text-white outline-none transition-colors focus:border-[#444]",style:{border:"1px solid #2a2a2a"}})]}),g.jsxs("button",{type:"submit",disabled:_,className:"flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60",children:[_&&g.jsx(qs,{className:"h-4 w-4 animate-spin","aria-hidden":"true"}),d?"Verify":"Verify and send"]}),g.jsx("button",{type:"button",onClick:()=>{f("email"),w(null),E(null)},className:"w-full cursor-pointer text-center text-xs text-[#666] transition-colors hover:text-[#aaa]",children:"Use a different email"})]}),h==="sending"&&g.jsxs("div",{className:"flex flex-col items-center gap-3 py-8",children:[g.jsx(qs,{className:"h-6 w-6 animate-spin text-white","aria-hidden":"true"}),g.jsx("p",{className:"text-sm text-[#aaa]",children:"Generating and encrypting locally..."})]}),h==="password"&&g.jsxs("div",{className:"space-y-4",children:[g.jsxs("div",{className:"flex items-start gap-2.5 rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2.5",children:[g.jsx(Gs,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400","aria-hidden":"true"}),g.jsxs("p",{className:"text-xs text-emerald-200",children:["Sent to ",X,". Open the attached PDF with this password."]})]}),g.jsxs("div",{children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"Your one-time password"}),g.jsxs("div",{className:"flex items-center gap-2 rounded-lg bg-black p-3",style:{border:"1px solid #2a2a2a"},children:[g.jsx("code",{className:"flex-1 break-all font-mono text-base text-white",children:M}),g.jsxs("button",{onClick:H,className:"flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-xs text-[#aaa] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-white",style:{border:"1px solid #2a2a2a"},children:[B?g.jsx(Gs,{className:"h-3.5 w-3.5"}):g.jsx(mo,{className:"h-3.5 w-3.5"}),B?"Copied":"Copy"]})]}),g.jsxs("p",{className:"mt-2 text-xs text-[#666]",children:["Save this now. Strix never stores it, so we cannot show it again. File:"," ",g.jsx("span",{className:"font-mono text-[#888]",children:R})]})]}),g.jsx("button",{onClick:()=>o("overview"),className:"w-full cursor-pointer rounded-lg px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-[rgba(255,255,255,0.06)]",style:{border:"1px solid #2a2a2a"},children:"Done"})]})]})]})}function la(e){return e&&typeof e=="object"&&!Array.isArray(e)?e:{}}function io(e){return Array.isArray(e)?e:[]}function nr(e){return typeof e=="string"&&e.trim()?e:null}function Ba(e){return typeof e=="number"&&Number.isFinite(e)?e:null}function JU(e){return e.replace(/_/g," ")}function w_(e){return e&&e.charAt(0).toUpperCase()+e.slice(1)}function eH(e){if(e==null||e<0)return"n/a";const t=Math.floor(e/3600),r=Math.floor(e%3600/60),a=e%60;return t?`${t}h ${r}m ${a}s`:r?`${r}m ${a}s`:`${a}s`}function wn({label:e,children:t}){return g.jsxs("div",{className:"grid grid-cols-[7rem_1fr] gap-3 items-baseline",children:[g.jsx("dt",{className:"text-[11px] uppercase tracking-wide text-[#666]",children:e}),g.jsx("dd",{className:"min-w-0 break-words text-sm text-[#ddd]",children:t})]})}function tH({raw:e,durationSeconds:t}){const[r,a]=ee.useState(!0),s=io(e.targets_info).map(P=>{const T=la(P),$=nr(T.original)??nr(la(T.details).target_url)??"unknown target",O=nr(T.type);return{display:$,type:O?JU(O):null}}),o=nr(e.instruction),c=w_(nr(e.scan_mode)),d=nr(e.scope_mode),h=la(e.diff_scope),f=h.active===!0,m=nr(h.mode),p=nr(e.diff_base),y=e.non_interactive===!0,x=io(e.local_sources).map(P=>{if(typeof P=="string")return P;const T=la(P);return nr(T.source_path)??nr(T.target_path)??""}).filter(Boolean),_=w_(nr(e.status));let N=d??"auto";f&&(N+=` (diff${m?`: ${m}`:""}${p?` vs ${p}`:""})`);const S=la(e.llm_usage),w=Object.keys(S).length>0,k=io(S.agents).map(la),E=Array.from(new Set(k.map(P=>nr(P.model)).filter(P=>!!P))),M=Ba(S.requests),I=Ba(S.input_tokens),R=Ba(la(io(S.input_tokens_details)[0]).cached_tokens),U=Ba(S.output_tokens),B=Ba(la(io(S.output_tokens_details)[0]).reasoning_tokens),Z=Ba(S.total_tokens),j=Ba(S.cost),z=nr(e.auth_mode)==="subscription",V=(P,T)=>g.jsxs("span",{className:"text-[#666]",children:[" (",Ds(P)," ",T,")"]});return g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:[g.jsxs("button",{type:"button",onClick:()=>a(P=>!P),"aria-expanded":r,className:"flex w-full cursor-pointer items-center gap-2 text-left",children:[g.jsx(OC,{className:"h-4 w-4 text-[#888]","aria-hidden":"true"}),g.jsx("h2",{className:"text-sm font-semibold text-white",children:"Run details"}),r?g.jsx(O_,{className:"ml-auto h-4 w-4 text-[#666]","aria-hidden":"true"}):g.jsx(ho,{className:"ml-auto h-4 w-4 text-[#666]","aria-hidden":"true"})]}),r&&g.jsxs("div",{className:"mt-4 grid grid-cols-1 gap-x-8 gap-y-6 md:grid-cols-2",children:[g.jsxs("section",{children:[g.jsx("h3",{className:"mb-3 text-[11px] font-semibold uppercase tracking-wide text-[#555]",children:"Configuration"}),g.jsxs("dl",{className:"space-y-2.5",children:[s.length>0&&g.jsx(wn,{label:"Targets",children:g.jsx("div",{className:"space-y-1",children:s.map((P,T)=>g.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[g.jsx("span",{className:"font-mono text-[#ddd]",children:P.display}),P.type&&g.jsx("span",{className:"rounded-full border border-[#2a2a2a] px-1.5 py-0.5 text-[10px] text-[#888]",children:P.type})]},T))})}),g.jsx(wn,{label:"Instruction",children:o?g.jsx("span",{className:"whitespace-pre-wrap",children:o}):g.jsx("span",{className:"text-[#666]",children:"None"})}),c&&g.jsx(wn,{label:"Pentest mode",children:c}),g.jsx(wn,{label:"Scope",children:N}),g.jsx(wn,{label:"Mode",children:y?"Non-interactive":"Interactive"}),x.length>0&&g.jsx(wn,{label:"Local sources",children:g.jsx("div",{className:"space-y-0.5 font-mono text-[#ddd]",children:x.map((P,T)=>g.jsx("div",{children:P},T))})}),_&&g.jsx(wn,{label:"Status",children:_})]})]}),g.jsxs("section",{children:[g.jsx("h3",{className:"mb-3 text-[11px] font-semibold uppercase tracking-wide text-[#555]",children:"Usage & cost"}),w?g.jsxs("dl",{className:"space-y-2.5 tabular-nums",children:[g.jsx(wn,{label:"Model",children:E.length?E.join(", "):"n/a"}),z&&g.jsx(wn,{label:"Provider",children:g.jsx("span",{className:"inline-flex items-center gap-1.5",children:g.jsx("span",{className:"rounded-full border border-[#22c55e]/40 bg-[#22c55e]/10 px-2 py-0.5 text-[11px] text-[#22c55e]",children:"ChatGPT subscription"})})}),g.jsx(wn,{label:"Run time",children:eH(t)}),M!=null&&g.jsx(wn,{label:"Requests",children:Ds(M)}),I!=null&&g.jsxs(wn,{label:"Input tokens",children:[Ds(I),R!=null&&V(R,"cached")]}),U!=null&&g.jsxs(wn,{label:"Output tokens",children:[Ds(U),B!=null&&V(B,"reasoning")]}),Z!=null&&g.jsx(wn,{label:"Total tokens",children:Ds(Z)}),z?g.jsxs(wn,{label:"Cost",children:[g.jsx("span",{className:"text-[#22c55e]",children:"$0.00"}),g.jsx("span",{className:"text-[#666]",children:" (subscription)"})]}):j!=null&&g.jsxs(wn,{label:"Cost",children:["$",j.toFixed(2)]}),k.length>0&&g.jsx(wn,{label:"Agents",children:Ds(k.length)})]}):g.jsx("p",{className:"text-sm text-[#666]",children:"Not available yet."})]})]})]})}const E_="strix_viewer_trust_dismissed";function nH({message:e}){const[t,r]=ee.useState(()=>{try{return localStorage.getItem(E_)==="1"}catch{return!1}});if(t)return null;const a=()=>{try{localStorage.setItem(E_,"1")}catch{}r(!0)};return g.jsx("div",{className:"fixed bottom-3 left-3 z-[60] max-w-xs rounded-lg bg-[#0a0a0a] p-3 shadow-2xl",style:{border:"1px solid #2a2a2a"},role:"status",children:g.jsxs("div",{className:"flex gap-2.5",children:[g.jsx(I_,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400","aria-hidden":"true"}),g.jsx("p",{className:"text-xs leading-relaxed text-[#aaa]",children:e}),g.jsx("button",{onClick:a,"aria-label":"Dismiss",className:"-mr-0.5 -mt-0.5 flex-shrink-0 cursor-pointer rounded p-0.5 text-[#666] transition-colors hover:text-white",children:g.jsx(vp,{className:"h-3.5 w-3.5"})})]})})}const rH=5e3,N_={invalid_email:"That email doesn't look right.",invalid_message:"Please write a little more.",unavailable:"Couldn't send that just now. Try again."};function iH({defaultEmail:e,onExit:t}){const[r,a]=ee.useState(""),[s,o]=ee.useState(e??""),[c,d]=ee.useState("form"),[h,f]=ee.useState(null),m=r.trim().length>0&&s.trim().length>0&&c!=="sending",p=async()=>{if(!m)return;d("sending"),f(null);const y=await wU(r.trim(),s.trim());if(y.ok){d("sent");return}d("form"),f(N_[y.error]??N_.unavailable)};return g.jsxs("div",{className:"mx-auto max-w-xl space-y-4",children:[g.jsxs("button",{onClick:()=>t("overview"),className:"cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white",children:[g.jsx(xp,{className:"h-4 w-4"}),"Back to results"]}),g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(pS,{className:"h-5 w-5 text-[#888]","aria-hidden":"true"}),g.jsx("h1",{className:"text-2xl font-semibold text-white",children:"Feedback & support"})]}),g.jsx("div",{className:"w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6",style:{border:"1px solid #2a2a2a"},children:c==="sent"?g.jsxs("div",{className:"flex items-start gap-3",children:[g.jsx(j_,{className:"mt-0.5 h-5 w-5 flex-shrink-0 text-emerald-400","aria-hidden":"true"}),g.jsxs("div",{className:"min-w-0",children:[g.jsx("p",{className:"text-sm font-semibold text-white",children:"Thanks, we got it."}),g.jsx("p",{className:"mt-1 text-xs text-[#888]",children:"We read every message. If it needs a reply, we'll reach out to the email you gave."}),g.jsx("button",{onClick:()=>{a(""),d("form")},className:"mt-4 cursor-pointer text-xs text-[#888] transition-colors hover:text-white",children:"Send more feedback"})]})]}):g.jsxs(g.Fragment,{children:[g.jsx("p",{className:"mb-4 text-xs text-[#666]",children:"Bugs, feature requests, or anything else. Tell us what's on your mind."}),h&&g.jsxs("div",{className:"mb-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2",children:[g.jsx(Hu,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-red-400","aria-hidden":"true"}),g.jsx("p",{className:"text-xs text-red-300",children:h})]}),g.jsxs("label",{className:"block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"Your feedback"}),g.jsx("textarea",{autoFocus:!0,value:r,maxLength:rH,onChange:y=>a(y.target.value),rows:5,placeholder:"What's working, what's not, what you'd love to see…",className:"w-full resize-y rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"})]}),g.jsxs("label",{className:"mt-4 block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"Your work email"}),g.jsx("input",{type:"email",value:s,onChange:y=>o(y.target.value),placeholder:"you@company.com",className:"w-full rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"})]}),g.jsx("button",{onClick:()=>void p(),disabled:!m,className:"mt-4 flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60",children:c==="sending"?"Sending…":"Send feedback"})]})})]})}function aH({text:e,children:t,className:r=""}){const[a,s]=ee.useState(!1);return g.jsxs("span",{className:`relative inline-flex ${r}`,onMouseEnter:()=>s(!0),onMouseLeave:()=>s(!1),onFocus:()=>s(!0),onBlur:()=>s(!1),children:[t,a&&g.jsx("span",{role:"tooltip",className:"pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-max max-w-[240px] -translate-x-1/2 rounded-md px-2.5 py-1.5 text-xs text-[#ddd] shadow-lg",style:{border:"1px solid #2a2a2a",background:"#0a0a0a"},children:e})]})}function gS({label:e,desc:t,slug:r,icon:a,surface:s}){return g.jsx(aH,{text:t,children:g.jsxs("a",{href:ha($u,r),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr(r,s),className:"group inline-flex items-center gap-2 rounded-lg border border-[#222] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-sm text-[#aaa] transition-colors hover:border-[#444] hover:text-white",children:[g.jsx(a,{className:"h-4 w-4 text-[#888] transition-colors group-hover:text-white","aria-hidden":"true"}),g.jsx("span",{children:e})]})})}const sH="Your findings stay on your machine. They're rendered here locally in your browser and never uploaded or stored by Strix.",S_=["critical","high","medium","low"],lH=500;function oH(){const[e,t]=ee.useState(null),[r,a]=ee.useState(null),[s,o]=ee.useState(null),[c,d]=ee.useState(null),[h,f]=ee.useState("overview"),[m,p]=ee.useState(null),[y,x]=ee.useState(null),[_,N]=ee.useState("report"),[S,w]=ee.useState(!1),[k,E]=ee.useState(!1),M=ee.useCallback(async()=>{try{p(await EU())}catch{}},[]),I=ee.useCallback(async()=>{try{x(await yU())}catch{}},[]);ee.useEffect(()=>{M(),I(),vU().then(C=>E(C.can_steer)).catch(()=>{})},[M,I]);const R=ee.useRef(!1);ee.useEffect(()=>{let C=!1,D;R.current=!1;const Y=()=>{D=setTimeout(L,lH)},L=async()=>{if(!C)try{const{summary:G,raw:q,finished:Q}=await lS(e);if(C)return;if(Q&&!R.current){R.current=!0;const te=await h_(e);C||a(te);return}const[J,W]=await Promise.all([cS(e).catch(()=>({agents:[],events:[]})),oS(G.runId,e).catch(()=>[])]);if(C)return;a(te=>({summary:G,raw:q,finished:Q,transcript:J,vulnerabilities:W,reportMarkdown:(te==null?void 0:te.reportMarkdown)??null})),Y()}catch(G){if(C)return;o(G instanceof Error?G.message:"Could not load run data."),Y()}};return(async()=>{try{const G=await h_(e);if(C)return;a(G),G.finished?R.current=!0:Y()}catch(G){if(C)return;o(G instanceof Error?G.message:"Could not load run data."),Y()}})(),()=>{C=!0,D&&clearTimeout(D)}},[e]);const U=ee.useMemo(()=>r?bU(r.vulnerabilities):null,[r]),B=(r==null?void 0:r.vulnerabilities.find(C=>C.id===c))??null,Z=(r==null?void 0:r.transcript.agents.length)??0,j=(m==null?void 0:m.verified)===!0,z=ee.useRef(!1);ee.useEffect(()=>{z.current=!1},[e]),ee.useEffect(()=>{z.current||!r||(r.finished?(z.current=!0,f("overview")):Z>0&&(z.current=!0,f("agents")))},[r,Z]);const V=ee.useCallback(C=>{z.current=!0,f(C)},[]),P=ee.useCallback(C=>{t(C),d(null),a(null),o(null),z.current=!1},[]),T=ee.useCallback((C,D)=>{jr("email_report",D),N("report"),w(C),V("email")},[V]),$=ee.useCallback(()=>T(!1,"sidebar"),[T]),O=ee.useCallback(()=>T(!0,"overview"),[T]),H=ee.useCallback(()=>{I(),V("history")},[I,V]),X=ee.useCallback(async()=>{await M(),await I()},[M,I]),K=ee.useCallback(async()=>{await NU(),await M(),await I()},[M,I]);return g.jsxs("div",{className:"min-h-screen bg-black text-white flex",children:[g.jsx(qU,{view:h,onSelectView:C=>{d(null),C==="history"?H():V(C)},issuesCount:(r==null?void 0:r.vulnerabilities.length)??0,agentCount:Z,runCount:(y==null?void 0:y.count)??0,finished:(r==null?void 0:r.finished)??!1,verified:j,email:(m==null?void 0:m.email)??null,onOpenEmail:$,onOpenHistory:H,onForget:()=>void K()}),g.jsxs("div",{className:"flex-1 min-w-0",children:[g.jsx("div",{className:"border-b border-[#222]",children:g.jsxs("div",{className:"max-w-[88rem] mx-auto px-3 sm:px-6 py-4 flex items-center gap-1.5",children:[g.jsxs("a",{href:ha("https://app.strix.ai","logo"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("logo","topbar"),className:"flex items-center gap-1.5 opacity-90 transition-opacity hover:opacity-100 lg:hidden",title:"Open Strix Cloud",children:[g.jsx("img",{src:"./logo.png",alt:"Strix",className:"w-10 h-8 object-cover"}),g.jsx("div",{className:"text-base text-white font-medium tracking-tight",children:"Strix"})]}),r&&g.jsx(uH,{finished:r.finished}),g.jsxs("div",{className:"ml-auto flex items-center gap-3",children:[j&&y&&!y.locked&&y.runs.length>0&&g.jsx(cH,{runs:y,activeRun:e,launchedName:bo((r==null?void 0:r.summary.targets[0])??null,(r==null?void 0:r.summary.runName)??(r==null?void 0:r.summary.runId)??"Current run"),onSelect:P}),g.jsxs("a",{href:ha($u,"run_in_cloud"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("run_in_cloud","topbar"),className:"inline-flex items-center gap-1 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity hover:opacity-90",children:["Run in the cloud",g.jsx(T_,{className:"w-3 h-3","aria-hidden":"true"})]})]})]})}),g.jsxs("div",{className:"max-w-[88rem] mx-auto px-3 sm:px-6 py-8 sm:py-12 space-y-6",children:[s&&!r&&h!=="history"&&h!=="email"&&g.jsxs("div",{className:"rounded-lg px-4 py-3 flex gap-3 items-start border border-red-500/30 bg-red-500/5",children:[g.jsx(Hu,{className:"w-5 h-5 flex-shrink-0 mt-0.5 text-red-400","aria-hidden":"true"}),g.jsx("p",{className:"text-sm text-red-300",children:s})]}),g.jsx("div",{className:"animate-page-in space-y-6",children:h==="email"?g.jsx(WU,{activeRun:e,auth:m,purpose:_,skipDisclosure:S,onAuthChanged:()=>{M(),I()},onExit:C=>f(C==="history"?"history":"overview")}):h==="feedback"?g.jsx(iH,{defaultEmail:(m==null?void 0:m.email)??null,onExit:C=>f(C)}):h==="history"?g.jsxs("div",{className:"space-y-4",children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(Vs,{className:"w-5 h-5 text-[#888]","aria-hidden":"true"}),g.jsx("h1",{className:"text-2xl font-semibold text-white",children:"Past runs"})]}),g.jsx(KU,{runs:y,activeRun:e,onSelectRun:P,onVerified:()=>void X()})]}):!r&&!s?g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-10 text-center",children:[g.jsx("div",{className:"w-6 h-6 mx-auto mb-3 rounded-full border-2 border-[#333] border-t-white animate-spin"}),g.jsx("p",{className:"text-sm text-[#888]",children:"Loading run data…"})]}):r&&U?g.jsxs(g.Fragment,{children:[g.jsx(fH,{summary:r.summary}),g.jsxs("div",{className:"flex gap-5 border-b border-[#2a2a2a] lg:hidden",children:[g.jsx(Bm,{active:h==="overview",onClick:()=>V("overview"),children:"Pentest Overview"}),g.jsxs(Bm,{active:h==="issues",onClick:()=>V("issues"),children:["Issues",r.vulnerabilities.length>0?` (${r.vulnerabilities.length})`:""]}),Z>0&&g.jsxs(Bm,{active:h==="agents",onClick:()=>V("agents"),children:["Agents (",Z,")"]})]}),h==="overview"?g.jsx(bH,{summary:r.summary,counts:U,total:r.vulnerabilities.length,reportMarkdown:r.reportMarkdown,raw:r.raw,finished:r.finished,onOpenEmail:O}):h==="agents"&&Z>0?g.jsx(xH,{run:r,canSteer:k}):B?g.jsxs("div",{className:"space-y-4",children:[g.jsxs("button",{onClick:()=>d(null),className:"cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] hover:text-white transition-colors",children:[g.jsx(xp,{className:"w-4 h-4"})," Back to all findings"]}),g.jsx(tD,{vulnerability:B})]}):g.jsx(hH,{vulnerabilities:r.vulnerabilities,finished:r.finished,onSelect:C=>d(C)})]}):null},`${e??"launched"}:${h}:${c??""}`)]})]}),g.jsx(nH,{message:sH})]})}function cH({runs:e,activeRun:t,launchedName:r,onSelect:a}){const[s,o]=ee.useState(!1),c=e.runs.find(h=>h.name===t),d=c?bo(c.target,c.name):r;return g.jsxs("div",{className:"relative",children:[g.jsxs("button",{onClick:()=>o(h=>!h),onBlur:()=>setTimeout(()=>o(!1),150),"aria-label":"Switch pentest",className:"flex items-center gap-2 rounded-lg border border-[#3a3a3a] bg-[rgba(255,255,255,0.05)] px-3 py-2 text-sm text-white transition-colors hover:border-[#555] hover:bg-[rgba(255,255,255,0.09)]",children:[g.jsx(Vs,{className:"h-4 w-4 flex-shrink-0 text-[#888]","aria-hidden":"true"}),g.jsx("span",{className:"flex-shrink-0 text-[#888]",children:"Pentest"}),g.jsx("span",{className:"max-w-[260px] truncate font-medium",children:d}),g.jsx(ho,{className:"h-4 w-4 flex-shrink-0 text-[#aaa]","aria-hidden":"true"})]}),s&&g.jsxs("div",{className:"absolute right-0 z-50 mt-2 max-h-96 w-96 overflow-y-auto rounded-xl py-1.5 shadow-2xl",style:{border:"1px solid #3a3a3a",background:"#0a0a0a"},children:[g.jsx("div",{className:"border-b border-[#222] px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[#666]",children:"Switch pentest"}),e.runs.map(h=>{const f=h.name===t;return g.jsxs("button",{onMouseDown:()=>a(h.name),className:`flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors hover:bg-[rgba(255,255,255,0.06)] ${f?"bg-[rgba(255,255,255,0.04)] text-white":"text-[#aaa]"}`,children:[g.jsxs("span",{className:"min-w-0 flex-1",children:[g.jsx("span",{className:"block truncate font-medium",children:bo(h.target,h.name)}),h.target&&g.jsx("span",{className:"block truncate font-mono text-xs text-[#666]",children:h.target})]}),f&&g.jsx("span",{className:"h-2 w-2 flex-shrink-0 rounded-full bg-emerald-400"})]},h.name)})]})]})}function uH({finished:e}){return e?g.jsxs("span",{className:"ml-3 inline-flex items-center gap-1.5 text-xs text-[#888]",children:[g.jsx("span",{className:"w-1.5 h-1.5 rounded-full bg-[#555]"}),"Complete"]}):g.jsxs("span",{className:"ml-3 inline-flex items-center gap-1.5 text-xs text-emerald-400",children:[g.jsxs("span",{className:"relative flex h-1.5 w-1.5",children:[g.jsx("span",{className:"absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75 animate-ping"}),g.jsx("span",{className:"relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-400"})]}),"Live"]})}function dH(e){if(e==null)return null;if(e<60)return`${e}s`;const t=Math.floor(e/60);return t<60?`${t}m`:`${Math.floor(t/60)}h ${t%60}m`}function fH({summary:e}){const t=dH(e.durationSeconds);return g.jsxs("div",{children:[g.jsx("h1",{className:"text-2xl font-semibold text-white",children:bo(e.targets[0]??null,e.runName??e.runId??"Pentest results")}),g.jsxs("div",{className:"mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[#888]",children:[e.targets.length>0&&g.jsx("span",{className:"font-mono text-[#aaa]",children:e.targets.join(", ")}),e.scanMode&&g.jsx(Im,{label:e.scanMode}),t&&g.jsx(Im,{label:t}),e.status&&g.jsx(Im,{label:e.status})]})]})}function Im({label:e}){return g.jsxs(g.Fragment,{children:[g.jsx("span",{className:"text-[#333]",children:"·"}),g.jsx("span",{className:"capitalize",children:e})]})}function hH({vulnerabilities:e,finished:t,onSelect:r}){const a=[...e].sort((s,o)=>S_.indexOf(s.severity)-S_.indexOf(o.severity));return a.length===0?g.jsxs("div",{className:"space-y-4",children:[g.jsx("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]",children:t?"No findings in this run.":"No findings yet. The pentest is still running…"}),t&&g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:[g.jsx("p",{className:"text-sm font-medium text-white",children:"Stay ahead of new exposures"}),g.jsx("p",{className:"mt-0.5 mb-3 text-xs text-[#666]",children:"Attack surface monitoring catches new exposures for your org over time."}),g.jsx(gS,{label:"Attack surface monitoring",desc:"Continuous coverage for your whole org.",slug:"asm",surface:"empty_state",icon:XC})]})]}):g.jsx("div",{className:"space-y-2",children:a.map(s=>g.jsxs("button",{onClick:()=>r(s.id),className:"animate-card-in cursor-pointer w-full text-left rounded-lg border border-[#222] hover:border-[#444] bg-[rgba(255,255,255,0.02)] px-4 py-3 transition-colors flex items-center gap-3",children:[g.jsx("span",{className:`w-2.5 h-2.5 rounded-full flex-shrink-0 ${_p(s.severity)}`,"aria-hidden":"true"}),g.jsxs("span",{className:"flex-1 min-w-0",children:[g.jsx("span",{className:"block text-sm font-medium text-white truncate",children:s.title}),s.target&&g.jsx("span",{className:"block text-xs text-[#666] font-mono truncate",children:s.target})]}),g.jsx("span",{className:`text-xs font-semibold px-2 py-0.5 rounded-full border capitalize ${U_[s.severity]}`,children:s.severity})]},s.id))})}function mH(e){return e.replace(/^\s*#{1,6}[ \t]+.*(?:\r?\n)+/,"").trimStart()}function pH(e){const t=[];let r=null;for(const a of e.split(` +`)}function z7({args:e,result:t}){const r=e.action??"",a=e.code??e.script??"",s=t;let o=null;s&&typeof s=="object"?o=typeof s.stdout=="string"?s.stdout:null:typeof s=="string"&&(o=s);const c=r==="new_session"?"new session":r==="close"?"close session":r==="list_sessions"?"list sessions":null,d=o?L7(D7(o)):null;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-yellow-400/80 font-semibold text-sm",children:"Python"}),c&&g.jsx("span",{className:"text-[#888] text-[13px]",children:c})]}),a&&g.jsx(dg,{code:a,language:"python",collapsible:!0}),d&&g.jsx(wi,{className:"text-[#666]",children:d})]})}function I7({args:e}){const t=e.targets,a=(Array.isArray(t)?t:[]).map(s=>typeof s=="object"&&s?s.original??null:null).filter(Boolean);return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-emerald-400/80 font-semibold text-sm",children:"Starting penetration test"}),a.length===1&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["on ",a[0]]})]}),a.length>1&&g.jsx("div",{className:"mt-1.5 space-y-0.5",children:a.map((s,o)=>g.jsxs("div",{className:"text-[13px] text-[#888]",children:[g.jsx("span",{className:"text-[#555] mr-1",children:"•"}),s]},o))})]})}function B7({args:e}){const t=e.name??"Unknown Agent",r=e.task??"";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-[#888] text-[13px]",children:"subagent"}),g.jsx("span",{className:"text-purple-400 font-semibold text-sm",children:t})]}),r&&g.jsx("div",{className:"mt-1.5",children:g.jsx(An,{text:r,maxLines:15})})]})}function U7(e){return e.toolName==="subagent_start_info"?g.jsx(B7,{...e}):g.jsx(I7,{...e})}function H7({args:e}){const t=e.executive_summary??"",r=e.methodology??"",a=e.technical_analysis??"",s=e.recommendations??"";return g.jsxs("div",{className:"space-y-3",children:[g.jsx("span",{className:"text-emerald-400/80 font-semibold text-sm",children:"Penetration test completed"}),t&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Executive Summary"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:t,maxLines:25})})]}),r&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Methodology"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:r,maxLines:25})})]}),a&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Technical Analysis"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:a,maxLines:25})})]}),s&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Recommendations"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:s,maxLines:25})})]}),!t&&!r&&!a&&!s&&g.jsx("div",{className:"text-[#555] text-xs",children:"Generating final report..."})]})}function $7({toolName:e,args:t,result:r}){if(e==="create_note"){const a=t.title??"",s=t.content??"",o=t.category??"general";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note"}),g.jsxs("span",{className:"text-[#555] text-[13px]",children:["(",o,")"]})]}),a&&g.jsx("div",{className:"mt-1.5 text-[#999] text-[13px]",children:a}),s&&g.jsx("div",{className:"mt-1",children:g.jsx(ua,{text:s})})]})}if(e==="delete_note")return g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note removed"});if(e==="update_note"){const a=t.title??"",s=t.content??"";return g.jsxs("div",{children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note updated"}),a&&g.jsx("div",{className:"mt-1.5 text-[#999] text-[13px]",children:a}),s&&g.jsx("div",{className:"mt-1",children:g.jsx(ua,{text:s})})]})}if(e==="get_note"){const a=r,s=a&&typeof a=="object"&&a.success?a.note:void 0;return g.jsxs("div",{children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note read"}),s&&g.jsxs(g.Fragment,{children:[g.jsxs("div",{className:"mt-1.5 text-[#999] text-[13px]",children:[s.title??"(untitled)",g.jsxs("span",{className:"text-[#555] ml-1",children:["(",s.category??"general",")"]}),(s.by_you||s.agent_name)&&g.jsxs("span",{className:"text-[#666] ml-1 text-xs",children:["by ",s.by_you?"you":s.agent_name]})]}),s.content&&g.jsx("div",{className:"mt-1",children:g.jsx(ua,{text:s.content})})]})]})}if(e==="list_notes"){const a=r;let s=[];if(a&&typeof a=="object"&&a.success){const o=a.notes;s=Array.isArray(o)?o:[]}return g.jsxs("div",{children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"notes"}),s.length>0?g.jsx("div",{className:"mt-1.5 space-y-0.5",children:s.map((o,c)=>g.jsxs("div",{className:"text-[13px]",children:[g.jsx("span",{className:"text-[#555] mr-1",children:"-"}),g.jsx("span",{className:"text-[#999]",children:o.title??"(untitled)"}),g.jsxs("span",{className:"text-[#555] ml-1",children:["(",o.category??"general",")"]}),(o.by_you||o.agent_name)&&g.jsxs("span",{className:"text-[#666] ml-1 text-xs",children:["by ",o.by_you?"you":o.agent_name]}),o.content&&g.jsx("div",{className:"ml-3",children:g.jsx(ua,{text:o.content})})]},c))}):g.jsx("div",{className:"mt-1 text-[#555] text-xs",children:"No notes"})]})}return g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note"})}const q7={create_todo:{label:"Task added",Icon:VC},list_todos:{label:"Plan",Icon:Gk},update_todo:{label:"Task updated",Icon:qC},mark_todo_done:{label:"Task completed",Icon:R_},mark_todo_pending:{label:"Task reopened",Icon:eT},delete_todo:{label:"Task removed",Icon:hT}};function P7({status:e}){return e==="done"?g.jsx(R_,{className:"w-3.5 h-3.5 text-emerald-400/70 shrink-0"}):e==="in_progress"?g.jsx(rC,{className:"w-3.5 h-3.5 text-purple-400/70 shrink-0 animate-pulse"}):g.jsx(aC,{className:"w-3.5 h-3.5 text-[#444] shrink-0"})}function F7({todos:e,highlightId:t}){return g.jsx("div",{className:"space-y-0",children:e.map((r,a)=>{const s=r.status??"pending",o=t&&r.id===t;return g.jsxs("div",{className:`flex items-start gap-2.5 py-1.5 px-2 -mx-2 rounded-md transition-colors ${o?"bg-purple-500/[0.08]":""}`,children:[g.jsx("div",{className:"mt-[1px]",children:g.jsx(P7,{status:s})}),g.jsx("span",{className:`text-[13px] leading-snug ${s==="done"?"text-[#555] line-through":s==="in_progress"?"text-[#bbb]":"text-[#999]"}`,children:r.title??"(untitled)"})]},r.id??a)})})}function G7({toolName:e,args:t,result:r}){const a=q7[e]??{label:"Plan",Icon:ZC},s=a.Icon,o=r;if(typeof o=="string"&&o.trim())return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(s,{className:"w-3.5 h-3.5 text-purple-400/60"}),g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:a.label})]}),g.jsx("div",{className:"mt-1.5 text-[#888] text-[13px]",children:o.trim()})]});let c=[],d=null,h;if(o&&typeof o=="object"){if(d=o.error??null,o.success){const m=o.todos;c=Array.isArray(m)?m:[]}h=o.id??t.todo_id??void 0}const f=e!=="list_todos"?h:void 0;return c.length===0&&!d?g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(s,{className:"w-3.5 h-3.5 text-purple-400/60"}),g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:a.label})]}):g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2 mb-2",children:[g.jsx(s,{className:"w-3.5 h-3.5 text-purple-400/60"}),g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:a.label})]}),d&&g.jsx("div",{className:"text-red-400/70 text-[13px] mb-2",children:d}),c.length>0&&g.jsx("div",{className:"rounded-lg border border-white/[0.06] bg-white/[0.015] px-3 py-2",children:g.jsx(F7,{todos:c,highlightId:f})})]})}function d_(e){if(e==null)return null;if(typeof e=="string")return e.trim()?e:null;if(typeof e=="object"){const t=e;if(typeof t.__raw=="string")return t.__raw;if(Object.keys(t).length===0)return null;try{return JSON.stringify(e,null,2)}catch{return String(e)}}return String(e)}function nS({toolName:e,args:t,result:r}){const a=d_(t),s=d_(r);return g.jsxs("div",{children:[g.jsx("span",{className:"text-[#888] font-semibold text-sm",children:e.replace(/_/g," ")}),a&&g.jsx(wi,{className:"text-[#777]",children:a}),s&&g.jsx(wi,{className:"text-[#666]",children:s})]})}function V7({args:e}){const t=e.skills,r=(Array.isArray(t)?t:String(t??"").split(",")).map(a=>String(a).trim()).filter(Boolean);return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-emerald-400/80 font-semibold text-sm",children:"Loading skill"}),r.length>0&&g.jsx("span",{className:"text-[#888] text-[13px]",children:r.join(", ")})]})}function Y7({args:e}){const t=e.message??"";return t?g.jsxs("div",{children:[g.jsx(ua,{text:t}),g.jsx("div",{className:"mt-1.5 text-[#888] text-[13px]",children:"waiting for your reply"})]}):null}const ad={terminal:{renderer:e7,icon:B_,color:"text-emerald-400"},python:{renderer:z7,icon:oC,color:"text-yellow-400"},browser:{renderer:n7,icon:z_,color:"text-blue-400"},filesystem:{renderer:r7,icon:gC,color:"text-sky-400"},proxy:{renderer:k7,icon:T_,color:"text-purple-400",match:/request|sitemap|scope/},reporting:{renderer:p7,icon:iT,color:"text-red-400"},thinking:{renderer:C7,icon:M_,color:"text-purple-400"},agents:{renderer:T7,icon:Ao,color:"text-cyan-400",match:/agent/},search:{renderer:A7,icon:nT,color:"text-amber-400"},lifecycle:{renderer:U7,icon:L_,color:"text-emerald-400"},notes:{renderer:$7,icon:uT,color:"text-amber-400",match:/note/},skills:{renderer:V7,icon:Hm,color:"text-emerald-400"},todos:{renderer:G7,icon:jC,color:"text-purple-400",match:/todo/},telemetry:{renderer:nS,icon:Hm,color:"text-[#555]"}},X7={terminal:["exec_command","write_stdin","terminal_execute"],python:["python_action"],browser:["browser_action"],filesystem:["apply_patch","view_image","str_replace_editor","list_files","search_files"],proxy:["list_requests","view_request","repeat_request","list_sitemap","view_sitemap_entry","scope_rules","send_request"],reporting:["create_vulnerability_report","list_reports","get_report"],thinking:["think"],agents:["create_agent","agent_finish","send_message_to_agent","wait_for_agents","view_agent_graph","stop_agent"],search:["web_search"],lifecycle:["scan_start_info","subagent_start_info","finish_scan","respond_to_user"],notes:["create_note","delete_note","update_note","list_notes","get_note"],skills:["load_skill"],todos:["create_todo","list_todos","update_todo","mark_todo_done","mark_todo_pending","delete_todo"],telemetry:["sandbox_error_details","llm_error_details"]},K7=Object.fromEntries(Object.entries(X7).flatMap(([e,t])=>t.map(r=>[r,e]))),Z7={finish_scan:H7,respond_to_user:Y7,apply_patch:u7,view_image:h7,list_reports:l_,get_report:l_},Q7={agent_finish:{icon:L_,color:"text-cyan-400"},send_message_to_agent:{icon:mh,color:"text-cyan-400"},wait_for_agents:{icon:mh,color:"text-cyan-400"},respond_to_user:{icon:mh,color:"text-emerald-400"},view_agent_graph:{icon:mC,color:"text-cyan-400"},stop_agent:{icon:A_,color:"text-red-400"},scan_start_info:{icon:dC,color:"text-emerald-400"},subagent_start_info:{icon:Ao,color:"text-purple-400"},view_image:{icon:AC,color:"text-sky-400"}},W7=ad.telemetry;function rS(e){var r;const t=K7[e];if(t)return t;for(const[a,s]of Object.entries(ad))if((r=s.match)!=null&&r.test(e))return a;return null}function J7(e){const t=Z7[e];if(t)return t;const r=rS(e);return r?ad[r].renderer:nS}function eU(e){const t=Q7[e];if(t)return t;const r=rS(e),a=r?ad[r]:W7;return{icon:a.icon,color:a.color}}const tU=30;function nU({role:e,content:t}){const r=e==="user"||e==="human";return g.jsxs("div",{children:[g.jsx("span",{className:`font-semibold text-sm ${r?"text-blue-400/80":"text-purple-400/80"}`,children:r?"User":"Thinking"}),g.jsx("div",{className:"mt-1.5 italic text-[#888]",children:g.jsx(An,{text:t,maxLines:tU})})]})}class rU extends ee.Component{constructor(t){super(t),this.state={hasError:!1}}static getDerivedStateFromError(){return{hasError:!0}}render(){return this.state.hasError?g.jsx("span",{className:"text-[#555] font-semibold text-sm",children:this.props.toolName.replace(/_/g," ")}):this.props.children}}function iU(e){const t=J7(e.toolName);return g.jsx(rU,{toolName:e.toolName,children:g.jsx(t,{...e})})}function iS(e){if(e==null||typeof e!="string")return e;const t=e.trim();if(!t)return e;try{return JSON.parse(t)}catch{}try{const r=t.replace(/\bNone\b/g,"null").replace(/\bTrue\b/g,"true").replace(/\bFalse\b/g,"false").replace(/'/g,'"');return JSON.parse(r)}catch{return{__raw:e}}}function aS(e){const t=iS(e);return t&&typeof t=="object"&&!Array.isArray(t)?t:t==null?{}:{__raw:typeof t=="string"?t:JSON.stringify(t)}}function f_(e){const t=/(\d+)$/.exec(e);return t?parseInt(t[1],10):0}function mg(e){var r;const t=(r=e.data)==null?void 0:r.role;return e.type==="chat"&&(t==="user"||t==="human")}function aU(e){var t;return mg(e)&&String(((t=e.data)==null?void 0:t.content)??"").startsWith("[Message from ")}function sU(e){const t=new Set;let r=!1;for(const a of e)if(mg(a)){if(aU(a)){t.add(a.id);continue}r||(r=!0,t.add(a.id))}return t}const lU={completed:"text-emerald-400 border-emerald-500/30 bg-emerald-500/10",running:"text-blue-400 border-blue-500/30 bg-blue-500/10",waiting:"text-yellow-400 border-yellow-500/30 bg-yellow-500/10",stopped:"text-[#aaa] border-[#333] bg-[#1a1a1a]",crashed:"text-red-400 border-red-500/30 bg-red-500/10",failed:"text-red-400 border-red-500/30 bg-red-500/10"};function oU(e){return e==="completed"?"completed":e==="running"?"running":e==="failed"||e==="crashed"?"failed":e}function cU(e,t){var d;const r=new Map;for(const h of e)if(h.parent_id){const f=r.get(h.parent_id)??[];f.push(h.id),r.set(h.parent_id,f)}const a=new Map,s=new Map,o=new Map;for(const h of t)if(h.type==="tool"){if(a.set(h.agent_id,(a.get(h.agent_id)??0)+1),((d=h.data)==null?void 0:d.tool_name)==="create_agent"){const f=aS(h.data.args),m=f.name??f.agent_name??"",p=f.task??"";m&&p&&o.set(m,p)}}else mg(h)||s.set(h.agent_id,(s.get(h.agent_id)??0)+1);const c=new Map;for(const h of e)c.set(h.id,{id:h.id,name:h.name,task:o.get(h.name)??"",status:oU(h.status),parentId:h.parent_id,children:r.get(h.id)??[],createdAt:h.created_at,toolCount:a.get(h.id)??0,messageCount:s.get(h.id)??0});return c}function uU({agent:e,events:t,showHeader:r=!0}){const a=ee.useMemo(()=>{const c=t.filter(h=>h.agent_id===e.id).sort((h,f)=>f_(h.id)-f_(f.id)),d=sU(c);return c.filter(h=>!d.has(h.id))},[t,e.id]),s=a.filter(c=>c.type==="tool").length,o=a.length-s;return g.jsxs("div",{children:[r&&g.jsxs(g.Fragment,{children:[g.jsxs("div",{className:"flex items-center gap-2 flex-wrap mb-1",children:[g.jsx("span",{className:"text-base font-semibold text-white truncate",children:e.name}),g.jsx("span",{className:`flex-shrink-0 text-xs font-medium capitalize px-2 py-0.5 rounded-full border ${lU[e.status]??"text-[#aaa] border-[#333] bg-[#1a1a1a]"}`,children:e.status}),g.jsx("span",{className:"font-mono text-xs text-[#555]",children:e.id})]}),g.jsxs("p",{className:"text-xs text-[#666] mb-4",children:[o," message",o===1?"":"s"," · ",s," tool call",s===1?"":"s"]})]}),a.length===0?g.jsx("p",{className:"text-sm text-[#666]",children:"No recorded activity for this agent."}):g.jsx("div",{className:"py-1",children:a.map((c,d)=>{var N,S,w,k,E,M;const h=d===a.length-1,f=c.type==="tool",m=f?String(((N=c.data)==null?void 0:N.tool_name)??"tool"):"",p=f?"":String(((S=c.data)==null?void 0:S.role)??"assistant");let y,x;if(f){const I=eU(m);y=I.icon,x=I.color}else{const I=p==="user"||p==="human";y=I?Ao:M_,x=I?"text-blue-400":"text-purple-400"}const _=f?String(((w=c.data)==null?void 0:w.status)??"completed"):"completed";return g.jsxs("div",{className:"flex gap-3",children:[g.jsxs("div",{className:"flex flex-col items-center shrink-0",children:[g.jsx("div",{className:`w-[30px] h-[30px] rounded-full bg-black border flex items-center justify-center shrink-0 ${f&&_==="running"?"border-blue-500/40 animate-pulse":f&&_==="failed"?"border-red-500/30":"border-[#222]"}`,children:g.jsx(y,{className:`w-3.5 h-3.5 ${x}`})}),!h&&g.jsx("div",{className:"w-px flex-1 bg-[#1a1a1a] mt-1"})]}),g.jsx("div",{className:"flex-1 min-w-0 pt-[5px] pb-6",children:f?g.jsx(iU,{toolName:m,args:aS((k=c.data)==null?void 0:k.args),result:iS((E=c.data)==null?void 0:E.result)??null,status:_}):g.jsx(nU,{role:p,content:String(((M=c.data)==null?void 0:M.content)??"")})})]},c.id)})})]})}class Iu extends Error{constructor(t){super(t),this.name="RunParseError"}}const dU=["critical","high","medium","low"];function fU(e){const t=String(e??"").toLowerCase().trim();return dU.includes(t)?t:"low"}function hU(e){if(typeof e=="string"&&e.trim()){const t=e.trim().replace(" UTC","Z").replace(" ","T"),r=new Date(t);if(!Number.isNaN(r.getTime()))return r.toISOString();const a=new Date(e);if(!Number.isNaN(a.getTime()))return a.toISOString()}return new Date().toISOString()}function Ot(e){return typeof e=="string"&&e.length>0?e:null}function mU(e){return typeof e=="number"&&Number.isFinite(e)?e:null}function sS(e,t){try{return JSON.parse(e)}catch{throw new Iu(`${t} isn't valid JSON. Make sure you selected a Strix run directory.`)}}function pU(e){const t=sS(e,"run.json");if(!t||typeof t!="object"||Array.isArray(t))throw new Iu("run.json is not an object.");const r=t,a=[],s=r.targets_info;if(Array.isArray(s)){for(const x of s)if(x&&typeof x=="object"){const _=x.original;typeof _=="string"&&_&&a.push(_)}}const o=Ot(r.start_time),c=Ot(r.end_time);let d=null;if(o&&c){const x=new Date(o).getTime(),_=new Date(c).getTime();!Number.isNaN(x)&&!Number.isNaN(_)&&_>=x&&(d=Math.round((_-x)/1e3))}let h=null,f=null,m=null,p=null;const y=r.scan_results;if(y&&typeof y=="object"){const x=y;h=Ot(x.executive_summary),f=Ot(x.technical_analysis),m=Ot(x.methodology),p=Ot(x.recommendations)}return{runId:Ot(r.run_id),runName:Ot(r.run_name),targets:a,scanMode:Ot(r.scan_mode),status:Ot(r.status),startTime:o,endTime:c,durationSeconds:d,executiveSummary:h,technicalAnalysis:f,methodology:m,recommendations:p}}function gU(){return{pr_review_id:null,cve:null,cvss:null,potential_risk_saving:null,risk_saving_description:null,impact:null,endpoint:null,method:null,target:null,technical_analysis:null,poc_description:null,poc_script_code:null,code_diff:null,code_file:null,code_before:null,code_after:null,cwe:null,code_locations:null,remediation_steps:null,fix_pr_body:null,evidence:null,assumptions:null,fix_effort:null,cvss_breakdown:null,status_changed_at:null,status_changed_by:null,status_note:null,snoozed_until:null,reopened_at:null,reopened_by:null,original_severity:null,severity_changed_at:null,severity_changed_by:null,severity_override_reason:null,retest_of_vulnerability_id:null}}function bU(e,t,r){const a=e.cwe,s=typeof a=="string"&&a.trim()?[a.trim()]:Array.isArray(a)?a.filter(c=>typeof c=="string"&&c):null;return{...gU(),id:Ot(e.id)??`vuln-${t+1}`,scan_id:r,title:Ot(e.title)??"Untitled finding",description:Ot(e.description)??"",severity:fU(e.severity),status:"open",created_at:hU(e.timestamp),cve:Ot(e.cve),cvss:mU(e.cvss),impact:Ot(e.impact),endpoint:Ot(e.endpoint),method:Ot(e.method),target:Ot(e.target),technical_analysis:Ot(e.technical_analysis),poc_description:Ot(e.poc_description),poc_script_code:Ot(e.poc_script_code),cwe:s,code_locations:Array.isArray(e.code_locations)?e.code_locations:null,remediation_steps:Ot(e.remediation_steps),fix_pr_body:Ot(e.fix_pr_body),evidence:Ot(e.evidence),assumptions:Ot(e.assumptions),fix_effort:Ot(e.fix_effort)??null,cvss_breakdown:e.cvss_breakdown??null}}function xU(e,t=null){const r=sS(e,"vulnerabilities.json");if(!Array.isArray(r))throw new Iu("vulnerabilities.json is not a JSON array.");return r.map((a,s)=>{if(!a||typeof a!="object")throw new Iu(`vulnerabilities.json entry #${s+1} is not an object.`);return bU(a,s,t)})}function yU(e){const t={critical:0,high:0,medium:0,low:0};for(const r of e)t[r.severity]+=1;return t}async function Ja(e){const t=await fetch(e,{cache:"no-store"});if(!t.ok)throw new Error(`${e} responded ${t.status}`);return t.json()}function sd(e){return e?`?run=${encodeURIComponent(e)}`:""}async function lS(e){const t=await Ja("/api/run"+sd(e)),r=pU(JSON.stringify(t)),a=t.finished===!0;return{summary:r,raw:t,finished:a}}async function oS(e,t){const r=await Ja("/api/vulnerabilities"+sd(t));return xU(JSON.stringify(r),e)}async function vU(e){const t=await Ja("/api/report"+sd(e));return(t==null?void 0:t.markdown)??null}async function cS(e){const t=await Ja("/api/transcript"+sd(e));return{agents:Array.isArray(t==null?void 0:t.agents)?t.agents:[],events:Array.isArray(t==null?void 0:t.events)?t.events:[]}}async function h_(e){const{summary:t,raw:r,finished:a}=await lS(e),[s,o,c]=await Promise.all([oS(t.runId,e).catch(()=>[]),vU(e).catch(()=>null),cS(e).catch(()=>({agents:[],events:[]}))]);return{summary:t,raw:r,finished:a,vulnerabilities:s,reportMarkdown:o,transcript:c}}async function rl(e,t){const r=await fetch(e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t),cache:"no-store"});let a={};try{const s=await r.json();s&&typeof s=="object"&&(a=s)}catch{}return{ok:r.ok,status:r.status,data:a}}async function _U(){const e=await Ja("/api/runs");return{locked:(e==null?void 0:e.locked)??!0,count:typeof(e==null?void 0:e.count)=="number"?e.count:0,runs:Array.isArray(e==null?void 0:e.runs)?e.runs:[]}}async function wU(){const e=await Ja("/api/capabilities");return{can_steer:(e==null?void 0:e.can_steer)===!0}}async function EU(e,t){const{ok:r,data:a}=await rl("/api/agents/steer",{agent_id:e,message:t});return r&&a.ok===!0?{ok:!0}:{ok:!1,error:String(a.error??"unavailable")}}async function NU(e,t){const{ok:r,data:a}=await rl("/api/feedback",{message:e,email:t});return r&&a.ok===!0?{ok:!0}:{ok:!1,error:String(a.error??"unavailable")}}async function SU(){const e=await Ja("/api/auth/status");return{verified:(e==null?void 0:e.verified)===!0,email:(e==null?void 0:e.email)??null}}async function uS(e){const{ok:t,data:r}=await rl("/api/auth/otp/start",{email:e});return t&&r.ok===!0?{ok:!0}:{ok:!1,error:String(r.error??"unavailable")}}async function dS(e,t){const{ok:r,data:a}=await rl("/api/auth/otp/verify",{email:e,code:t});return r&&a.verified===!0?{verified:!0,email:String(a.email??e)}:{verified:!1,error:String(a.error??"invalid_code")}}async function kU(){await rl("/api/auth/forget",{})}async function CU(e){const{ok:t,data:r}=await rl("/api/report/send",e?{run:e}:{});return t&&r.ok===!0?{ok:!0,password:String(r.password??""),filename:String(r.filename??"strix-report.pdf")}:{ok:!1,error:String(r.error??"unavailable")}}const Bs="__root__";function fS({agents:e,fixedAgentId:t,className:r}){const a=ee.useRef(null),[s,o]=ee.useState(!1),[c,d]=ee.useState(!1),[h,f]=ee.useState(""),[m,p]=ee.useState(!1),[y,x]=ee.useState(null),_=t!=null,N=ee.useMemo(()=>e.find(z=>!z.parent_id)??e[0]??null,[e]),S=ee.useMemo(()=>e.filter(z=>z.parent_id&&z.status==="running"),[e]),[w,k]=ee.useState(Bs),[E,M]=ee.useState(!1);ee.useEffect(()=>{w!==Bs&&!S.some(z=>z.id===w)&&k(Bs)},[w,S]);const{targetId:I,targetName:R}=ee.useMemo(()=>{if(_){const V=e.find(P=>P.id===t)??null;return{targetId:t??null,targetName:(V==null?void 0:V.name)??"this agent"}}if(w===Bs)return{targetId:(N==null?void 0:N.id)??null,targetName:"Root agent"};const z=e.find(V=>V.id===w)??null;return{targetId:(z==null?void 0:z.id)??(N==null?void 0:N.id)??null,targetName:(z==null?void 0:z.name)??"Root agent"}},[e,t,_,N,w]),U=h.trim().length===0;ee.useLayoutEffect(()=>{const z=a.current;z&&(z.style.height="auto",z.style.height=`${z.scrollHeight}px`)},[h]);const B=ee.useCallback(()=>{o(!0),requestAnimationFrame(()=>{var z;return(z=a.current)==null?void 0:z.focus()})},[]),Z=ee.useCallback(()=>{o(!1),d(!1),M(!1)},[]),j=ee.useCallback(async()=>{if(m)return;const z=h.trim();if(!z||!I)return;p(!0),x(null);const V=R,P=await EU(I,z);p(!1),P.ok?(f(""),x(`Sent to ${V}`),Tr("agent_steered")):P.error==="not_delivered"?x("Could not reach that agent (it may have finished)."):x("Could not send that message. Try again.")},[m,h,I,R]);return s?g.jsxs("div",{className:Mr("mt-4 rounded-2xl border border-white/[0.08] bg-[#050505] overflow-hidden transition-colors duration-300",c?"border-white/[0.18]":"hover:border-white/[0.12]",r),children:[g.jsxs("div",{className:"flex items-center justify-between gap-3 border-b border-white/[0.06] px-5 py-3",children:[g.jsxs("div",{className:"min-w-0",children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(Um,{className:"h-4 w-4 text-[#666]"}),g.jsx("p",{className:"text-sm font-medium text-white",children:"Live prompt"})]}),g.jsx("p",{className:"mt-0.5 text-xs text-[#777]",children:"Connected"})]}),g.jsxs("div",{className:"flex shrink-0 items-center gap-2",children:[_?g.jsxs("div",{className:"rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1 text-xs text-[#aaa]",children:["Target: ",g.jsx("span",{className:"text-white",children:R})]}):g.jsxs("div",{className:"flex items-center gap-1.5",children:[g.jsx("span",{className:"text-xs text-[#aaa]",children:"Target:"}),g.jsxs("div",{className:"relative",children:[g.jsxs("button",{type:"button",onClick:()=>M(z=>!z),onBlur:()=>requestAnimationFrame(()=>M(!1)),className:"inline-flex h-7 items-center gap-1 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 text-xs text-white transition-colors hover:border-white/[0.16] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20","aria-haspopup":"listbox","aria-expanded":E,children:[g.jsx("span",{className:"max-w-[140px] truncate",children:R}),g.jsx(ho,{className:"h-3.5 w-3.5 text-[#999]"})]}),E&&g.jsxs("div",{className:"absolute right-0 z-10 mt-1 min-w-[160px] overflow-hidden rounded-lg border border-[#333] bg-[#0a0a0a] py-1 shadow-xl",role:"listbox",children:[g.jsx(m_,{label:"Root agent",active:w===Bs,onSelect:()=>{k(Bs),M(!1)}}),S.map(z=>g.jsx(m_,{label:z.name,active:w===z.id,onSelect:()=>{k(z.id),M(!1)}},z.id))]})]})]}),g.jsx("button",{type:"button",onClick:Z,className:"inline-flex h-7 w-7 items-center justify-center rounded-full text-[#777] transition-colors hover:bg-white/[0.06] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20","aria-label":"Collapse live prompt composer",children:g.jsx(ho,{className:"h-4 w-4"})})]})]}),g.jsx("div",{className:"px-5 pt-4 pb-3",children:g.jsx("textarea",{ref:a,rows:1,value:h,onChange:z=>f(z.target.value),onFocus:()=>d(!0),onBlur:()=>d(!1),onKeyDown:z=>{z.key==="Enter"&&!z.shiftKey&&(z.preventDefault(),j())},placeholder:"Send a live prompt to the running pentest…",maxLength:4e3,disabled:m,className:"block w-full resize-none border-0 bg-transparent p-0 text-[15px] leading-6 text-white placeholder:text-[#444] focus:outline-none disabled:opacity-60 max-h-[160px] overflow-y-auto"})}),g.jsxs("div",{className:"flex items-center justify-between gap-3 px-4 pb-4",children:[g.jsx("div",{className:"text-xs text-[#666]",children:y??"Press Enter to send."}),g.jsxs("button",{type:"button",onClick:z=>{z.stopPropagation(),j()},disabled:m||U,className:Mr("inline-flex h-10 min-w-[112px] items-center justify-center gap-2 rounded-full px-4 text-sm font-medium transition-colors",m||U?"bg-white/[0.08] text-[#666]":"bg-white text-black hover:bg-neutral-200"),children:[m?g.jsx(qs,{className:"h-4 w-4 animate-spin"}):g.jsx(zk,{className:"h-4 w-4",strokeWidth:2.5}),g.jsx("span",{children:"Send prompt"})]})]})]}):g.jsxs("button",{type:"button",onClick:B,className:Mr("mt-4 flex w-full items-center justify-between gap-3 rounded-2xl border border-white/[0.08] bg-[#050505] px-5 py-3 text-left transition-colors duration-300 hover:border-white/[0.12] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20",r),"aria-expanded":!1,"aria-label":"Expand live prompt composer",children:[g.jsxs("div",{className:"flex min-w-0 items-center gap-2",children:[g.jsx(Um,{className:"h-4 w-4 shrink-0 text-[#666]"}),g.jsx("span",{className:"truncate text-sm font-medium text-white",children:"Guide the agent"})]}),g.jsx(O_,{className:"h-4 w-4 shrink-0 text-[#777]"})]})}function m_({label:e,active:t,onSelect:r}){return g.jsx("button",{type:"button",role:"option","aria-selected":t,onMouseDown:a=>{a.preventDefault(),r()},className:Mr("block w-full truncate px-3 py-1.5 text-left text-xs transition-colors hover:bg-white/[0.06]",t?"text-white":"text-[#aaa]"),children:e})}const TU={completed:"bg-emerald-400",running:"bg-blue-400",waiting:"bg-yellow-400",stopped:"bg-[#888]",crashed:"bg-red-400",failed:"bg-red-400"},AU=80;function MU({open:e,agent:t,events:r,steerable:a,onClose:s}){const o=ee.useRef(null),c=ee.useRef(!1),[d,h]=ee.useState(e),[f,m]=ee.useState(e?"open":"closed"),[p,y]=ee.useState(!1),x=ee.useRef(t);ee.useEffect(()=>{t&&(x.current=t)},[t]);const _=t??x.current;ee.useEffect(()=>{if(e){h(!0),m("open");return}m("closed");const S=setTimeout(()=>h(!1),140);return()=>clearTimeout(S)},[e]),ee.useEffect(()=>{if(!d){y(!1);return}const S=requestAnimationFrame(()=>y(!0));return()=>cancelAnimationFrame(S)},[d]);const N=ee.useCallback(()=>{const S=o.current;S&&(c.current=S.scrollHeight-S.scrollTop-S.clientHeight{const S=o.current;!S||!c.current||requestAnimationFrame(()=>{S.scrollTo({top:S.scrollHeight,behavior:"smooth"})})},[r]),ee.useEffect(()=>{if(!d)return;const S=k=>{k.key==="Escape"&&s()};document.addEventListener("keydown",S);const w=document.body.style.overflow;return document.body.style.overflow="hidden",()=>{document.removeEventListener("keydown",S),document.body.style.overflow=w}},[d,s]),!d||!_?null:g.jsx("div",{"data-state":f,className:"agent-modal fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 sm:p-8",onClick:s,role:"dialog","aria-modal":"true","aria-label":`Agent ${_.name}`,children:g.jsxs("div",{className:"relative flex h-[60vh] w-[calc(100vw-4rem)] max-w-6xl flex-col overflow-hidden rounded-xl border border-[#222] bg-[#0a0a0a] shadow-2xl",onClick:S=>S.stopPropagation(),children:[g.jsxs("div",{className:"flex items-center justify-between gap-3 border-b border-[#222] px-5 py-3.5",children:[g.jsxs("div",{className:"flex min-w-0 items-center gap-2",children:[g.jsx("span",{className:`h-2 w-2 flex-shrink-0 rounded-full ${TU[_.status]??"bg-[#888]"}`}),g.jsx("span",{className:"truncate text-sm font-semibold text-white",children:_.name}),g.jsx("span",{className:"flex-shrink-0 font-mono text-xs text-[#555]",children:_.id})]}),g.jsx("button",{type:"button",onClick:s,"aria-label":"Close",className:"flex-shrink-0 rounded-md p-1 text-[#888] transition-colors hover:bg-[#1a1a1a] hover:text-white",children:g.jsx(vp,{className:"h-4 w-4"})})]}),g.jsx("div",{ref:o,onScroll:N,className:"flex-1 overflow-y-auto p-5",children:p&&g.jsx(uU,{agent:_,events:r,showHeader:!1})}),a&&g.jsx("div",{className:"border-t border-[#222] px-5 py-3",children:g.jsx(fS,{agents:[_],fixedAgentId:_.id,className:"mt-0"})})]})})}var hS={color:void 0,size:void 0,className:void 0,style:void 0,attr:void 0},p_=da.createContext&&da.createContext(hS),OU=["attr","size","title"];function RU(e,t){if(e==null)return{};var r,a,s=jU(e,t);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(e);for(a=0;ada.createElement(t.tag,Uu({key:r},t.attr),mS(t.child)))}function pg(e){return t=>da.createElement(IU,Bu({attr:Uu({},e.attr)},t),mS(e.child))}function IU(e){var t=r=>{var a=e.attr,s=e.size,o=e.title,c=RU(e,OU),d=s||r.size||"1em",h;return r.className&&(h=r.className),e.className&&(h=(h?h+" ":"")+e.className),da.createElement("svg",Bu({stroke:"currentColor",fill:"currentColor",strokeWidth:"0"},r.attr,a,c,{className:h,style:Uu(Uu({color:e.color||r.color},r.style),e.style),height:d,width:d,xmlns:"http://www.w3.org/2000/svg"}),o&&da.createElement("title",null,o),e.children)};return p_!==void 0?da.createElement(p_.Consumer,null,r=>t(r)):t(hS)}function BU(e){return pg({attr:{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round"},child:[{tag:"circle",attr:{cx:"5",cy:"6",r:"3"},child:[]},{tag:"path",attr:{d:"M5 9v12"},child:[]},{tag:"circle",attr:{cx:"19",cy:"18",r:"3"},child:[]},{tag:"path",attr:{d:"m15 9-3-3 3-3"},child:[]},{tag:"path",attr:{d:"M12 6h5a2 2 0 0 1 2 2v7"},child:[]}]})(e)}function UU(e){return pg({attr:{viewBox:"0 0 16 16",fill:"currentColor"},child:[{tag:"path",attr:{d:"M15 4.95703C15 4.58711 14.8563 4.24054 14.5949 3.97992L12.0096 1.39234C11.4879 0.86922 10.5788 0.86922 10.0571 1.39234L8 3.45119V3.32321C8 2.55068 7.37187 1.922 6.6 1.922H2.4C1.62813 1.922 1 2.55068 1 3.32321V13.5988C1 14.3713 1.62813 15 2.4 15H12.6667C13.4385 15 14.0667 14.3713 14.0667 13.5988V9.39514C14.0667 8.62261 13.4385 7.99393 12.6667 7.99393H12.5379L14.5949 5.93508C14.8553 5.67445 15 5.32602 15 4.95703ZM2.4 2.85521H6.6C6.85667 2.85521 7.06667 3.06446 7.06667 3.32228V7.99299H1.93333V3.32228C1.93333 3.06446 2.14333 2.85521 2.4 2.85521ZM1.93333 13.5979V8.92714H7.06667V14.0649H2.4C2.14333 14.0649 1.93333 13.8547 1.93333 13.5979ZM13.1333 9.39421V13.5979C13.1333 13.8547 12.9233 14.0649 12.6667 14.0649H8V8.92714H12.6667C12.9233 8.92714 13.1333 9.13638 13.1333 9.39421ZM8 7.99299V6.46287L9.5288 7.99299H8ZM13.9351 5.2737L11.3488 7.86221C11.1789 8.03223 10.8859 8.03223 10.716 7.86221L8.12973 5.2737C8.0448 5.18963 7.99813 5.07753 7.99813 4.95796C7.99813 4.83839 8.0448 4.7263 8.12973 4.64129L10.716 2.05278C10.8009 1.96777 10.9129 1.92106 11.0324 1.92106C11.1519 1.92106 11.2639 1.96777 11.3488 2.05278L13.9351 4.64129C14.02 4.72536 14.0667 4.83746 14.0667 4.95703C14.0667 5.0766 14.02 5.1887 13.9351 5.2737Z"},child:[]}]})(e)}function pS(e){return pg({attr:{viewBox:"0 0 512 512"},child:[{tag:"path",attr:{fill:"none",strokeLinecap:"round",strokeMiterlimit:"10",strokeWidth:"32",d:"M431 320.6c-1-3.6 1.2-8.6 3.3-12.2a34 34 0 0 1 2.1-3.1A162 162 0 0 0 464 215c.3-92.2-77.5-167-173.7-167-83.9 0-153.9 57.1-170.3 132.9a160.7 160.7 0 0 0-3.7 34.2c0 92.3 74.8 169.1 171 169.1 15.3 0 35.9-4.6 47.2-7.7s22.5-7.2 25.4-8.3a26.4 26.4 0 0 1 9.3-1.7 26 26 0 0 1 10.1 2l56.7 20.1a13.5 13.5 0 0 0 3.9 1 8 8 0 0 0 8-8 13 13 0 0 0-.5-2.7z"},child:[]},{tag:"path",attr:{fill:"none",strokeLinecap:"round",strokeMiterlimit:"10",strokeWidth:"32",d:"M66.46 232a146.23 146.23 0 0 0 6.39 152.67c2.31 3.49 3.61 6.19 3.21 8s-11.93 61.87-11.93 61.87a8 8 0 0 0 2.71 7.68A8.17 8.17 0 0 0 72 464a7.3 7.3 0 0 0 2.91-.6l56.21-22a15.7 15.7 0 0 1 12 .2c18.94 7.38 39.88 12 60.83 12A159.2 159.2 0 0 0 284 432.11"},child:[]}]})(e)}const HU=[{icon:_C,label:"PR security reviews"},{icon:lT,label:"Attack surface monitoring"},{icon:ET,label:"Real-time threat intelligence"},{icon:Pk,label:"Scheduled pentesting"},{icon:yT,label:"One-click autofix"},{icon:FC,label:"Jira, Linear & Slack integrations"}];function $U({open:e,onClose:t,description:r,source:a="sidebar"}){const[s,o]=ee.useState(e),[c,d]=ee.useState(e?"open":"closed");return ee.useEffect(()=>{if(e){o(!0),d("open");return}d("closed");const h=setTimeout(()=>o(!1),200);return()=>clearTimeout(h)},[e]),ee.useEffect(()=>{if(!s)return;const h=m=>{m.key==="Escape"&&t()};document.addEventListener("keydown",h);const f=document.body.style.overflow;return document.body.style.overflow="hidden",()=>{document.removeEventListener("keydown",h),document.body.style.overflow=f}},[s,t]),s?g.jsx("div",{"data-state":c,className:"dialog-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4",onClick:t,role:"dialog","aria-modal":"true","aria-label":"Upgrade your plan",children:g.jsxs("div",{"data-state":c,className:"dialog-panel relative w-full max-w-md rounded-2xl border border-[#222] bg-black p-6 shadow-lg sm:rounded-lg",onClick:h=>h.stopPropagation(),children:[g.jsx("button",{type:"button",onClick:t,"aria-label":"Close",className:"absolute right-4 top-4 rounded-md p-1 text-[#888] transition-colors hover:bg-[#1a1a1a] hover:text-white",children:g.jsx(vp,{className:"h-4 w-4"})}),g.jsxs("div",{children:[g.jsx("h2",{className:"text-lg text-white",children:"Available in Strix Cloud"}),r&&g.jsx("p",{className:"mt-2 text-base leading-relaxed text-[#e5e5e5]",children:r})]}),g.jsxs("div",{className:"space-y-4 pt-4",children:[g.jsxs("div",{className:"rounded-xl border border-[#333] bg-[#0a0a0a] p-4 sm:rounded-lg",children:[g.jsxs("div",{className:"mb-3 flex items-center gap-2",children:[g.jsx(Um,{className:"h-4 w-4 text-blue-400"}),g.jsx("span",{className:"text-sm font-medium text-white",children:"Strix Cloud also includes"})]}),g.jsx("ul",{className:"space-y-2 text-sm text-[#888]",children:HU.map(h=>g.jsxs("li",{className:"flex items-center gap-2",children:[g.jsx(h.icon,{className:"h-3.5 w-3.5 text-[#555]"}),h.label]},h.label))})]}),g.jsxs("div",{className:"flex flex-col gap-2",children:[g.jsxs("a",{href:ha($u,"upgrade_try_free"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("upgrade_try_free",a),className:"flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-white px-4 text-sm font-semibold text-black transition-colors hover:bg-neutral-200",children:["Open Strix Cloud",g.jsx(ry,{className:"h-3.5 w-3.5"})]}),g.jsxs("a",{href:ha(TT,"upgrade_view_plans"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("upgrade_view_plans",a),className:"flex h-9 w-full items-center justify-center gap-1.5 rounded-lg border border-[#333] px-4 text-sm font-medium text-[#888] transition-colors hover:border-[#555] hover:text-white",children:["Learn more",g.jsx(ry,{className:"h-3 w-3"})]})]})]})]})}):null}const Lm=160,zm=260,ro=400,qU=140,b_="strix_viewer_sidebar_width",x_="strix_viewer_sidebar_collapsed";function PU(e,t){try{const r=localStorage.getItem(e),a=r?parseInt(r,10):NaN;return Number.isFinite(a)?a:t}catch{return t}}function FU({view:e,onSelectView:t,issuesCount:r,agentCount:a,runCount:s,finished:o,verified:c,email:d,onOpenEmail:h,onOpenHistory:f,onForget:m}){var z;const[p,y]=ee.useState(()=>{const V=PU(b_,zm);return Math.min(ro,Math.max(Lm,V))}),[x,_]=ee.useState(()=>{try{return localStorage.getItem(x_)==="1"}catch{return!1}}),[N,S]=ee.useState(!1),[w,k]=ee.useState(!1),[E,M]=ee.useState(null),I=ee.useRef(null),R=(V,P)=>{jr(V,"sidebar"),M(P)},U=ee.useCallback(V=>{y(V);try{localStorage.setItem(b_,String(V))}catch{}},[]),B=ee.useCallback(V=>{_(V);try{localStorage.setItem(x_,V?"1":"0")}catch{}},[]),Z=ee.useCallback(()=>{B(!1),U(zm)},[B,U]),j=ee.useCallback(V=>{V.preventDefault(),S(!0)},[]);return ee.useEffect(()=>{if(!N||x)return;const V=T=>{const $=T.clientX;$>=Lm&&$<=ro?y($):$>ro&&y(ro)},P=T=>{const $=T.clientX;${window.removeEventListener("mousemove",V),window.removeEventListener("mouseup",P)}},[N,x,B,U]),ee.useEffect(()=>{if(!w)return;const V=P=>{I.current&&!I.current.contains(P.target)&&k(!1)};return document.addEventListener("mousedown",V),()=>document.removeEventListener("mousedown",V)},[w]),g.jsxs(g.Fragment,{children:[x&&g.jsx("div",{className:"fixed left-0 top-0 z-40 hidden h-full w-4 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.08)] lg:block",onClick:Z,title:"Expand sidebar"}),g.jsxs("aside",{className:Mr("sticky top-0 z-20 hidden h-screen flex-shrink-0 flex-col overflow-hidden border-r border-[rgba(255,255,255,0.08)] bg-black lg:flex",!N&&"transition-[width] duration-200 ease-out"),style:{width:x?0:p},children:[g.jsx("header",{className:"relative flex flex-col gap-1 pt-1 min-w-[160px]",children:g.jsx("div",{className:"flex flex-row py-1 px-2",children:g.jsxs("div",{className:"flex h-10 w-full flex-row items-center",children:[g.jsxs("a",{href:ha("https://app.strix.ai","logo"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("logo","sidebar"),className:"flex flex-1 flex-row items-center gap-2 rounded-md py-2 pl-2.5 pr-1 min-w-0 transition-colors hover:bg-[rgba(255,255,255,0.06)]",title:"Open Strix Cloud",children:[g.jsx("span",{className:"flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500",style:{width:20,height:20},children:g.jsx("span",{className:"text-[10px] font-semibold text-white",children:"S"})}),g.jsxs("span",{className:"flex flex-1 flex-row items-center gap-2 min-w-0",children:[g.jsx("span",{className:"truncate min-w-0 text-[14px] font-medium text-[#ededed]",children:"Strix"}),g.jsx("span",{className:"flex h-5 flex-shrink-0 items-center rounded px-2 text-[11px] font-medium text-[#888] bg-[rgba(255,255,255,0.08)]",children:"Local"})]})]}),g.jsx("a",{href:ha("https://app.strix.ai","logo"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("logo","sidebar"),className:"flex flex-none items-center rounded-md px-1.5 py-2 transition-colors hover:bg-[rgba(255,255,255,0.06)]","aria-label":"Open Strix Cloud",children:g.jsx(Wk,{className:"h-4 w-4 text-[#666]"})})]})})}),g.jsx("nav",{className:"relative min-w-[160px] flex-1 overflow-y-auto overflow-x-clip scrollbar-thin pb-10 pt-2",children:g.jsxs("div",{className:"relative flex flex-col gap-px px-2",children:[g.jsx(yi,{icon:g.jsx(GU,{}),label:"Pentest Overview",active:e==="overview",onClick:()=>t("overview")}),g.jsx(yi,{icon:g.jsx(pT,{className:"h-4 w-4"}),label:"Issues",count:r>0?r:void 0,active:e==="issues",onClick:()=>t("issues")}),a>0&&g.jsx(yi,{icon:g.jsx(Ao,{className:"h-4 w-4"}),label:"Agents",count:a,active:e==="agents",onClick:()=>t("agents")}),g.jsx(yi,{icon:g.jsx(Vs,{className:"h-4 w-4"}),label:"Past runs",count:s>0?s:void 0,active:e==="history",onClick:f}),o&&g.jsx(yi,{icon:g.jsx(yp,{className:"h-4 w-4"}),label:"Export report",active:e==="email",onClick:h}),g.jsx(yi,{icon:g.jsx(pS,{className:"h-4 w-4"}),label:"Feedback & support",active:e==="feedback",onClick:()=>t("feedback")}),g.jsx("hr",{className:"mx-0 my-1 h-px w-full border-0 bg-[rgba(255,255,255,0.08)]"}),g.jsx(yi,{icon:g.jsx(BU,{className:"h-4 w-4"}),label:"PR Security Reviews",active:!1,onClick:()=>R("pr_reviews","Strix reviews every pull request and flags exploitable changes before they merge.")}),g.jsx(yi,{icon:g.jsx(UU,{className:"h-4 w-4"}),label:"Integrations",active:!1,onClick:()=>R("integrations","Sync findings to Jira, Linear, and Slack so fixes happen where your team already works.")}),g.jsx(yi,{icon:g.jsx(bT,{className:"h-4 w-4"}),label:"Members",active:!1,onClick:()=>R("members","Invite your team, set roles, and share findings and run history across your org.")})]})}),g.jsx("section",{className:"flex min-w-[160px] flex-col gap-0.5",ref:I,children:g.jsxs("div",{className:"relative p-2",children:[c&&d?g.jsxs("button",{onClick:()=>k(V=>!V),className:"relative flex w-full cursor-pointer items-center gap-2 rounded-md bg-transparent px-2.5 py-2 transition-colors hover:bg-[rgba(255,255,255,0.06)]",children:[g.jsx("span",{className:"flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500",style:{width:20,height:20},children:g.jsx("span",{className:"text-[9px] font-semibold text-white",children:((z=d[0])==null?void 0:z.toUpperCase())||"U"})}),g.jsxs("span",{className:"flex min-w-0 flex-1 flex-col text-left",children:[g.jsx("span",{className:"truncate text-[13px] font-medium text-[#ededed]",children:d}),g.jsx("span",{className:"truncate text-[11px] text-[#555]",children:"Linked to this machine"})]})]}):g.jsxs("div",{className:"flex items-center gap-2 rounded-md px-2.5 py-2",children:[g.jsx("span",{className:"flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500",style:{width:20,height:20},children:g.jsx("span",{className:"text-[9px] font-semibold text-white",children:"S"})}),g.jsx("span",{className:"flex min-w-0 flex-1 flex-col text-left",children:g.jsx("span",{className:"truncate text-[13px] font-medium text-[#ededed]",children:"Local viewer"})})]}),w&&c&&d&&g.jsxs("div",{className:"absolute bottom-full left-2 right-2 z-50 mb-1 overflow-hidden rounded-lg border border-[#333] bg-black shadow-xl",children:[g.jsxs("div",{className:"border-b border-[#333] px-3 py-2",children:[g.jsx("p",{className:"truncate text-[13px] font-medium text-white",children:"Linked email"}),g.jsx("p",{className:"truncate text-[11px] text-[#666]",children:d})]}),g.jsxs("button",{onClick:()=>{k(!1),m()},className:"flex w-full items-center gap-2 px-3 py-2 text-[13px] text-[#888] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-red-400",children:[g.jsx(BC,{className:"h-4 w-4"}),"Forget this email"]})]})]})}),g.jsx("div",{className:"group absolute right-0 top-0 z-30 h-full w-1 cursor-col-resize",onMouseDown:j,children:g.jsx("div",{className:Mr("absolute right-0 top-0 h-full w-px bg-[rgba(255,255,255,0.08)] transition-all duration-100",N?"w-0.5 bg-[rgba(255,255,255,0.3)]":"group-hover:bg-[rgba(255,255,255,0.2)]")})})]}),N&&g.jsx("div",{className:"fixed inset-0 z-10 cursor-col-resize"}),g.jsx($U,{open:E!==null,description:E??"",source:"sidebar",onClose:()=>M(null)})]})}function yi({icon:e,label:t,active:r,onClick:a,count:s}){return g.jsxs("button",{onClick:a,className:Mr("group flex h-9 w-full origin-left flex-row items-center rounded-md transition-colors",r?"bg-[rgba(255,255,255,0.12)] text-white":"text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-[#ededed]"),children:[g.jsx("div",{className:"grid flex-none place-content-center",style:{width:36,height:36},children:e}),g.jsx("span",{className:"min-w-0 flex-1 truncate text-left text-[14px] font-medium",children:t}),s!=null&&g.jsx("span",{className:"mr-2 flex-none rounded-full border border-white/10 px-2 py-0.5 text-[10px] tabular-nums leading-none text-[#777]",children:s})]})}function GU(){return g.jsx("svg",{style:{width:16,height:16,color:"currentcolor"},viewBox:"0 0 16 16",fill:"currentColor",children:g.jsx("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M2.5 5.5V2.5H5.5V5.5H2.5ZM1 2C1 1.44772 1.44772 1 2 1H6C6.55228 1 7 1.44772 7 2V6C7 6.55228 6.55228 7 6 7H2C1.44772 7 1 6.55228 1 6V2ZM2.5 13.5V10.5H5.5V13.5H2.5ZM1 10C1 9.44772 1.44772 9 2 9H6C6.55228 9 7 9.44772 7 10V14C7 14.5523 6.55228 15 6 15H2C1.44772 15 1 14.5523 1 14V10ZM10.5 2.5V5.5H13.5V2.5H10.5ZM10 1C9.44772 1 9 1.44772 9 2V6C9 6.55228 9.44772 7 10 7H14C14.5523 7 15 6.55228 15 6V2C15 1.44772 14.5523 1 14 1H10ZM10.5 13.5V10.5H13.5V13.5H10.5ZM9 10C9 9.44772 9.44772 9 10 9H14C14.5523 9 15 9.44772 15 10V14C15 14.5523 14.5523 15 14 15H10C9.44772 15 9 14.5523 9 14V10Z"})})}const y_={work_email_required:"Please use your work email, not a personal one.",rate_limited:"Too many requests. Wait a minute and try again.",invalid_email:"That email does not look right. Check it and try again.",unavailable:"The email service is unavailable right now. Try again shortly."},VU=new Set(["gmail.com","googlemail.com","yahoo.com","ymail.com","outlook.com","hotmail.com","live.com","icloud.com","me.com","aol.com","proton.me","protonmail.com","gmx.com","mail.com"]);function YU({onVerified:e}){const[t,r]=ee.useState("email"),[a,s]=ee.useState(""),[o,c]=ee.useState(""),[d,h]=ee.useState(!1),[f,m]=ee.useState(null),[p,y]=ee.useState(null),x=async()=>{const N=a.trim();if(!N){m("Enter your email to continue.");return}const S=N.slice(N.lastIndexOf("@")+1).toLowerCase();if(VU.has(S)){Tr("work_email_required"),m(y_.work_email_required);return}h(!0),m(null);const w=await uS(N);h(!1),w.ok?(Tr("email_submitted",{purpose:"verify"}),y(`We sent a 6-digit code to ${N}.`),r("code")):(w.error==="work_email_required"&&Tr("work_email_required"),m(y_[w.error]??"Could not send a code. Try again."))},_=async()=>{const N=o.trim();if(N.length<4){m("Enter the 6-digit code from your email.");return}h(!0),m(null);const S=await dS(a.trim(),N);if(h(!1),!S.verified){m("That code did not match. Check it and try again.");return}Tr("email_verified",{purpose:"verify"}),e()};return g.jsxs("div",{className:"mx-auto mt-5 max-w-sm text-left",children:[f&&g.jsxs("div",{className:"mb-3 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2",children:[g.jsx(Hu,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-red-400","aria-hidden":"true"}),g.jsx("p",{className:"text-xs text-red-300",children:f})]}),p&&!f&&g.jsx("p",{className:"mb-3 text-xs text-[#888]",children:p}),t==="email"?g.jsxs("form",{className:"space-y-3",onSubmit:N=>{N.preventDefault(),x()},children:[g.jsxs("label",{className:"block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"Your work email"}),g.jsx("input",{type:"email",autoFocus:!0,value:a,onChange:N=>s(N.target.value),placeholder:"you@company.com",className:"w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]",style:{border:"1px solid #2a2a2a"}}),g.jsx("span",{className:"mt-1.5 block text-[11px] text-[#666]",children:"Use your work email."})]}),g.jsxs("button",{type:"submit",disabled:d,className:"flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60",children:[d&&g.jsx(qs,{className:"h-4 w-4 animate-spin","aria-hidden":"true"}),"Send me a code"]})]}):g.jsxs("form",{className:"space-y-3",onSubmit:N=>{N.preventDefault(),_()},children:[g.jsxs("label",{className:"block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"6-digit code"}),g.jsx("input",{inputMode:"numeric",autoFocus:!0,value:o,onChange:N=>c(N.target.value.replace(/\D/g,"").slice(0,6)),placeholder:"123456",className:"w-full rounded-lg bg-black px-3 py-2.5 text-center text-lg font-mono tracking-[0.4em] text-white outline-none transition-colors focus:border-[#444]",style:{border:"1px solid #2a2a2a"}})]}),g.jsxs("button",{type:"submit",disabled:d,className:"flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60",children:[d&&g.jsx(qs,{className:"h-4 w-4 animate-spin","aria-hidden":"true"}),"Verify"]}),g.jsx("button",{type:"button",onClick:()=>{r("email"),m(null),y(null)},className:"w-full cursor-pointer text-center text-xs text-[#666] transition-colors hover:text-[#aaa]",children:"Use a different email"})]})]})}const XU=[{key:"critical",dot:"bg-red-500",text:"text-red-500"},{key:"high",dot:"bg-orange-500",text:"text-orange-500"},{key:"medium",dot:"bg-yellow-500",text:"text-yellow-500"},{key:"low",dot:"bg-blue-500",text:"text-blue-500"}];function KU({counts:e}){const t=XU.filter(r=>e[r.key]>0);return t.length===0?g.jsx("span",{className:"text-xs text-[#555]",children:"No findings"}):g.jsx("div",{className:"flex items-center gap-3",children:t.map(r=>g.jsxs("div",{className:"flex items-center gap-1.5",children:[g.jsx("span",{className:`h-2 w-2 rounded-full ${r.dot}`,"aria-hidden":"true"}),g.jsx("span",{className:`text-xs tabular-nums ${r.text}`,children:e[r.key]})]},r.key))})}function ZU(e){if(!e)return null;const t=e.trim().replace(" UTC","Z").replace(" ","T"),r=new Date(t);return Number.isNaN(r.getTime())?null:r.toLocaleString(void 0,{month:"short",day:"numeric",hour:"numeric",minute:"2-digit"})}function v_(e){if(!e)return null;const t=e.trim().replace(" UTC","Z").replace(" ","T"),r=new Date(t);if(Number.isNaN(r.getTime()))return null;const a=Date.now()-r.getTime(),s=Math.floor(a/6e4);if(s<1)return"just now";if(s<60)return`${s}m ago`;const o=Math.floor(s/60);if(o<24)return`${o}h ago`;const c=Math.floor(o/24);return c<7?`${c}d ago`:ZU(e)}function QU({runs:e,activeRun:t,onSelectRun:r,onVerified:a}){const s=(e==null?void 0:e.count)??0,[o,c]=ee.useState(!1);return!e||e.locked?g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center",children:[g.jsx("div",{className:"mx-auto mb-4 flex h-11 w-11 items-center justify-center rounded-xl",style:{border:"1px solid #2a2a2a",background:"rgba(255,255,255,0.04)"},children:g.jsx(Vs,{className:"h-5 w-5 text-[#888]","aria-hidden":"true"})}),g.jsx("h2",{className:"text-base font-semibold text-white",children:"Browse every run on this machine"}),g.jsxs("p",{className:"mx-auto mt-1.5 max-w-md text-sm text-[#888]",children:["You have ",s," past ",s===1?"run":"runs"," on this machine."]}),o?g.jsxs(g.Fragment,{children:[g.jsx("p",{className:"mx-auto mt-3 max-w-sm text-xs text-[#666]",children:"Verify your email with a one-time code to unlock the full history."}),g.jsx(YU,{onVerified:a})]}):g.jsx("button",{onClick:()=>{jr("history_unlock","past_runs"),c(!0)},className:"mt-4 cursor-pointer rounded-lg bg-white px-4 py-2 text-sm font-semibold text-black transition-opacity hover:opacity-90",children:"View runs"}),g.jsxs("p",{className:"mt-4 flex items-center justify-center gap-1.5 text-xs text-[#555]",children:[g.jsx(B_,{className:"h-3.5 w-3.5","aria-hidden":"true"}),"Or open one from the CLI with"," ",g.jsx("code",{className:"font-mono text-[#888]",children:"strix view "})]})]}):e.runs.length===0?g.jsx("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]",children:"No past runs found on this machine yet."}):g.jsx("div",{className:"space-y-2",children:e.runs.map(d=>{const h=d.name===t,f=v_(d.start_time)??v_(d.end_time),m=bo(d.target,d.name);return g.jsxs("button",{onClick:()=>r(d.name),className:`animate-card-in group flex w-full cursor-pointer items-center gap-4 rounded-lg border px-4 py-3 text-left transition-colors ${h?"border-[#444] bg-[rgba(255,255,255,0.04)]":"border-[#222] bg-[rgba(255,255,255,0.02)] hover:border-[#444]"}`,children:[g.jsxs("div",{className:"min-w-0 flex-1",children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"truncate text-sm font-medium text-white",children:m}),h&&g.jsx("span",{className:"rounded-full px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-400",style:{border:"1px solid rgba(16,185,129,0.3)"},children:"Active"})]}),g.jsxs("div",{className:"mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-[#666]",children:[d.scan_mode&&g.jsx("span",{className:"capitalize",children:d.scan_mode}),d.scan_mode&&(f||d.status)&&g.jsx("span",{className:"text-[#333]",children:"·"}),f&&g.jsx("span",{children:f}),f&&d.status&&g.jsx("span",{className:"text-[#333]",children:"·"}),d.status&&g.jsx("span",{className:"capitalize",children:d.status})]})]}),g.jsx(KU,{counts:d.severity_counts}),g.jsx(Kk,{className:"h-4 w-4 flex-shrink-0 text-[#555] transition-colors group-hover:text-[#aaa]","aria-hidden":"true"})]},d.name)})})}const __={work_email_required:"Please use your work email, not a personal one.",rate_limited:"Too many requests. Wait a minute and try again.",invalid_email:"That email does not look right. Check it and try again.",unavailable:"The email service is unavailable right now. Try again shortly."},WU={forbidden:"This email was unsubscribed from Strix, so we cannot send to it.",too_large:"This report is too large to email. Try a smaller run.",unavailable:"The email service is unavailable right now. Try again shortly."},JU=new Set(["gmail.com","googlemail.com","yahoo.com","ymail.com","outlook.com","hotmail.com","live.com","icloud.com","me.com","aol.com","proton.me","protonmail.com","gmx.com","mail.com"]);function eH({activeRun:e,auth:t,purpose:r,skipDisclosure:a=!1,onAuthChanged:s,onExit:o}){const c=(t==null?void 0:t.verified)===!0,d=r==="verify",[h,f]=ee.useState(()=>d?"email":a?c?"sending":"email":"disclosure"),[m,p]=ee.useState((t==null?void 0:t.email)??""),[y,x]=ee.useState(""),[_,N]=ee.useState(!1),[S,w]=ee.useState(null),[k,E]=ee.useState(null),[M,I]=ee.useState(""),[R,U]=ee.useState(""),[B,Z]=ee.useState(!1),[j,z]=ee.useState(""),V=ee.useRef(!1),P=async()=>{f("sending"),w(null);const K=await CU(e);if(K.ok){Tr("report_sent"),I(K.password),U(K.filename),f("password");return}if(K.error==="reverify"||K.error==="unverified"){E("Your verification expired. Enter your email to verify again."),f("email");return}w(WU[K.error]??"Could not send the report. Try again."),f("disclosure")},T=()=>{w(null),E(null),c?P():f("email")};ee.useEffect(()=>{!d&&a&&c&&!V.current&&(V.current=!0,P())},[]);const $=async()=>{const K=m.trim();if(!K){w("Enter your email to continue.");return}const C=K.slice(K.lastIndexOf("@")+1).toLowerCase();if(JU.has(C)){Tr("work_email_required"),w(__.work_email_required);return}N(!0),w(null);const D=await uS(K);N(!1),D.ok?(Tr("email_submitted",{purpose:r}),E(`We sent a 6-digit code to ${K}.`),f("code")):(D.error==="work_email_required"&&Tr("work_email_required"),w(__[D.error]??"Could not send a code. Try again."))},O=async()=>{const K=y.trim();if(K.length<4){w("Enter the 6-digit code from your email.");return}N(!0),w(null);const C=await dS(m.trim(),K);if(N(!1),!C.verified){w("That code did not match. Check it and try again.");return}Tr("email_verified",{purpose:r}),z(C.email),s(),d?o("history"):P()},H=async()=>{try{await navigator.clipboard.writeText(M),Z(!0),setTimeout(()=>Z(!1),1500)}catch{}},X=j||(t==null?void 0:t.email)||m.trim();return g.jsxs("div",{className:"mx-auto max-w-xl space-y-4",children:[g.jsxs("button",{onClick:()=>o(d?"history":"overview"),className:"cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white",children:[g.jsx(xp,{className:"h-4 w-4"}),d?"Back to past runs":"Back to results"]}),g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(yp,{className:"h-5 w-5 text-[#888]","aria-hidden":"true"}),g.jsx("h1",{className:"text-2xl font-semibold text-white",children:d?"Verify your email":"Export report to PDF"})]}),g.jsxs("div",{className:"w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6",style:{border:"1px solid #2a2a2a"},children:[g.jsx("p",{className:"mb-4 text-xs text-[#666]",children:d?"We send a one-time code to confirm it is you.":"Verified by a one-time code sent to your email"}),S&&g.jsxs("div",{className:"mb-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2",children:[g.jsx(Hu,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-red-400","aria-hidden":"true"}),g.jsx("p",{className:"text-xs text-red-300",children:S})]}),k&&!S&&h!=="password"&&g.jsx("p",{className:"mb-4 text-xs text-[#888]",children:k}),h==="disclosure"&&g.jsxs("div",{className:"space-y-4",children:[g.jsxs("div",{className:"space-y-2.5 rounded-lg p-3.5",style:{border:"1px solid #222",background:"rgba(255,255,255,0.02)"},children:[g.jsxs("div",{className:"flex items-start gap-2.5",children:[g.jsx(I_,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400","aria-hidden":"true"}),g.jsxs("p",{className:"text-xs leading-relaxed text-[#aaa]",children:["We email an ",g.jsx("span",{className:"text-white",children:"encrypted PDF"}),". Nothing else leaves your machine."]})]}),g.jsxs("div",{className:"flex items-start gap-2.5",children:[g.jsx(zC,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-[#888]","aria-hidden":"true"}),g.jsx("p",{className:"text-xs leading-relaxed text-[#aaa]",children:"Only you hold the password; Strix can't read it."})]})]}),g.jsx("button",{onClick:T,className:"w-full cursor-pointer rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90",children:"Export report"}),c&&(t==null?void 0:t.email)&&g.jsxs("p",{className:"text-center text-xs text-[#666]",children:["Sending to ",t.email]})]}),h==="email"&&g.jsxs("form",{className:"space-y-4",onSubmit:K=>{K.preventDefault(),$()},children:[g.jsxs("label",{className:"block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"Your work email"}),g.jsx("input",{type:"email",autoFocus:!0,value:m,onChange:K=>p(K.target.value),placeholder:"you@company.com",className:"w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]",style:{border:"1px solid #2a2a2a"}})]}),g.jsxs("button",{type:"submit",disabled:_,className:"flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60",children:[_&&g.jsx(qs,{className:"h-4 w-4 animate-spin","aria-hidden":"true"}),"Send me a code"]})]}),h==="code"&&g.jsxs("form",{className:"space-y-4",onSubmit:K=>{K.preventDefault(),O()},children:[g.jsxs("label",{className:"block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"6-digit code"}),g.jsx("input",{inputMode:"numeric",autoFocus:!0,value:y,onChange:K=>x(K.target.value.replace(/\D/g,"").slice(0,6)),placeholder:"123456",className:"w-full rounded-lg bg-black px-3 py-2.5 text-center text-lg font-mono tracking-[0.4em] text-white outline-none transition-colors focus:border-[#444]",style:{border:"1px solid #2a2a2a"}})]}),g.jsxs("button",{type:"submit",disabled:_,className:"flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60",children:[_&&g.jsx(qs,{className:"h-4 w-4 animate-spin","aria-hidden":"true"}),d?"Verify":"Verify and send"]}),g.jsx("button",{type:"button",onClick:()=>{f("email"),w(null),E(null)},className:"w-full cursor-pointer text-center text-xs text-[#666] transition-colors hover:text-[#aaa]",children:"Use a different email"})]}),h==="sending"&&g.jsxs("div",{className:"flex flex-col items-center gap-3 py-8",children:[g.jsx(qs,{className:"h-6 w-6 animate-spin text-white","aria-hidden":"true"}),g.jsx("p",{className:"text-sm text-[#aaa]",children:"Generating and encrypting locally..."})]}),h==="password"&&g.jsxs("div",{className:"space-y-4",children:[g.jsxs("div",{className:"flex items-start gap-2.5 rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2.5",children:[g.jsx(Gs,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400","aria-hidden":"true"}),g.jsxs("p",{className:"text-xs text-emerald-200",children:["Sent to ",X,". Open the attached PDF with this password."]})]}),g.jsxs("div",{children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"Your one-time password"}),g.jsxs("div",{className:"flex items-center gap-2 rounded-lg bg-black p-3",style:{border:"1px solid #2a2a2a"},children:[g.jsx("code",{className:"flex-1 break-all font-mono text-base text-white",children:M}),g.jsxs("button",{onClick:H,className:"flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-xs text-[#aaa] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-white",style:{border:"1px solid #2a2a2a"},children:[B?g.jsx(Gs,{className:"h-3.5 w-3.5"}):g.jsx(mo,{className:"h-3.5 w-3.5"}),B?"Copied":"Copy"]})]}),g.jsxs("p",{className:"mt-2 text-xs text-[#666]",children:["Save this now. Strix never stores it, so we cannot show it again. File:"," ",g.jsx("span",{className:"font-mono text-[#888]",children:R})]})]}),g.jsx("button",{onClick:()=>o("overview"),className:"w-full cursor-pointer rounded-lg px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-[rgba(255,255,255,0.06)]",style:{border:"1px solid #2a2a2a"},children:"Done"})]})]})]})}function la(e){return e&&typeof e=="object"&&!Array.isArray(e)?e:{}}function io(e){return Array.isArray(e)?e:[]}function nr(e){return typeof e=="string"&&e.trim()?e:null}function Ba(e){return typeof e=="number"&&Number.isFinite(e)?e:null}function tH(e){return e.replace(/_/g," ")}function w_(e){return e&&e.charAt(0).toUpperCase()+e.slice(1)}function nH(e){if(e==null||e<0)return"n/a";const t=Math.floor(e/3600),r=Math.floor(e%3600/60),a=e%60;return t?`${t}h ${r}m ${a}s`:r?`${r}m ${a}s`:`${a}s`}function wn({label:e,children:t}){return g.jsxs("div",{className:"grid grid-cols-[7rem_1fr] gap-3 items-baseline",children:[g.jsx("dt",{className:"text-[11px] uppercase tracking-wide text-[#666]",children:e}),g.jsx("dd",{className:"min-w-0 break-words text-sm text-[#ddd]",children:t})]})}function rH({raw:e,durationSeconds:t}){const[r,a]=ee.useState(!0),s=io(e.targets_info).map(P=>{const T=la(P),$=nr(T.original)??nr(la(T.details).target_url)??"unknown target",O=nr(T.type);return{display:$,type:O?tH(O):null}}),o=nr(e.instruction),c=w_(nr(e.scan_mode)),d=nr(e.scope_mode),h=la(e.diff_scope),f=h.active===!0,m=nr(h.mode),p=nr(e.diff_base),y=e.non_interactive===!0,x=io(e.local_sources).map(P=>{if(typeof P=="string")return P;const T=la(P);return nr(T.source_path)??nr(T.target_path)??""}).filter(Boolean),_=w_(nr(e.status));let N=d??"auto";f&&(N+=` (diff${m?`: ${m}`:""}${p?` vs ${p}`:""})`);const S=la(e.llm_usage),w=Object.keys(S).length>0,k=io(S.agents).map(la),E=Array.from(new Set(k.map(P=>nr(P.model)).filter(P=>!!P))),M=Ba(S.requests),I=Ba(S.input_tokens),R=Ba(la(io(S.input_tokens_details)[0]).cached_tokens),U=Ba(S.output_tokens),B=Ba(la(io(S.output_tokens_details)[0]).reasoning_tokens),Z=Ba(S.total_tokens),j=Ba(S.cost),z=nr(e.auth_mode)==="subscription",V=(P,T)=>g.jsxs("span",{className:"text-[#666]",children:[" (",Ds(P)," ",T,")"]});return g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:[g.jsxs("button",{type:"button",onClick:()=>a(P=>!P),"aria-expanded":r,className:"flex w-full cursor-pointer items-center gap-2 text-left",children:[g.jsx(OC,{className:"h-4 w-4 text-[#888]","aria-hidden":"true"}),g.jsx("h2",{className:"text-sm font-semibold text-white",children:"Run details"}),r?g.jsx(O_,{className:"ml-auto h-4 w-4 text-[#666]","aria-hidden":"true"}):g.jsx(ho,{className:"ml-auto h-4 w-4 text-[#666]","aria-hidden":"true"})]}),r&&g.jsxs("div",{className:"mt-4 grid grid-cols-1 gap-x-8 gap-y-6 md:grid-cols-2",children:[g.jsxs("section",{children:[g.jsx("h3",{className:"mb-3 text-[11px] font-semibold uppercase tracking-wide text-[#555]",children:"Configuration"}),g.jsxs("dl",{className:"space-y-2.5",children:[s.length>0&&g.jsx(wn,{label:"Targets",children:g.jsx("div",{className:"space-y-1",children:s.map((P,T)=>g.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[g.jsx("span",{className:"font-mono text-[#ddd]",children:P.display}),P.type&&g.jsx("span",{className:"rounded-full border border-[#2a2a2a] px-1.5 py-0.5 text-[10px] text-[#888]",children:P.type})]},T))})}),g.jsx(wn,{label:"Instruction",children:o?g.jsx("span",{className:"whitespace-pre-wrap",children:o}):g.jsx("span",{className:"text-[#666]",children:"None"})}),c&&g.jsx(wn,{label:"Pentest mode",children:c}),g.jsx(wn,{label:"Scope",children:N}),g.jsx(wn,{label:"Mode",children:y?"Non-interactive":"Interactive"}),x.length>0&&g.jsx(wn,{label:"Local sources",children:g.jsx("div",{className:"space-y-0.5 font-mono text-[#ddd]",children:x.map((P,T)=>g.jsx("div",{children:P},T))})}),_&&g.jsx(wn,{label:"Status",children:_})]})]}),g.jsxs("section",{children:[g.jsx("h3",{className:"mb-3 text-[11px] font-semibold uppercase tracking-wide text-[#555]",children:"Usage & cost"}),w?g.jsxs("dl",{className:"space-y-2.5 tabular-nums",children:[g.jsx(wn,{label:"Model",children:E.length?E.join(", "):"n/a"}),z&&g.jsx(wn,{label:"Provider",children:g.jsx("span",{className:"inline-flex items-center gap-1.5",children:g.jsx("span",{className:"rounded-full border border-[#22c55e]/40 bg-[#22c55e]/10 px-2 py-0.5 text-[11px] text-[#22c55e]",children:"ChatGPT subscription"})})}),g.jsx(wn,{label:"Run time",children:nH(t)}),M!=null&&g.jsx(wn,{label:"Requests",children:Ds(M)}),I!=null&&g.jsxs(wn,{label:"Input tokens",children:[Ds(I),R!=null&&V(R,"cached")]}),U!=null&&g.jsxs(wn,{label:"Output tokens",children:[Ds(U),B!=null&&V(B,"reasoning")]}),Z!=null&&g.jsx(wn,{label:"Total tokens",children:Ds(Z)}),z?g.jsxs(wn,{label:"Cost",children:[g.jsx("span",{className:"text-[#22c55e]",children:"$0.00"}),g.jsx("span",{className:"text-[#666]",children:" (subscription)"})]}):j!=null&&g.jsxs(wn,{label:"Cost",children:["$",j.toFixed(2)]}),k.length>0&&g.jsx(wn,{label:"Agents",children:Ds(k.length)})]}):g.jsx("p",{className:"text-sm text-[#666]",children:"Not available yet."})]})]})]})}const E_="strix_viewer_trust_dismissed";function iH({message:e}){const[t,r]=ee.useState(()=>{try{return localStorage.getItem(E_)==="1"}catch{return!1}});if(t)return null;const a=()=>{try{localStorage.setItem(E_,"1")}catch{}r(!0)};return g.jsx("div",{className:"fixed bottom-3 left-3 z-[60] max-w-xs rounded-lg bg-[#0a0a0a] p-3 shadow-2xl",style:{border:"1px solid #2a2a2a"},role:"status",children:g.jsxs("div",{className:"flex gap-2.5",children:[g.jsx(I_,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400","aria-hidden":"true"}),g.jsx("p",{className:"text-xs leading-relaxed text-[#aaa]",children:e}),g.jsx("button",{onClick:a,"aria-label":"Dismiss",className:"-mr-0.5 -mt-0.5 flex-shrink-0 cursor-pointer rounded p-0.5 text-[#666] transition-colors hover:text-white",children:g.jsx(vp,{className:"h-3.5 w-3.5"})})]})})}const aH=5e3,N_={invalid_email:"That email doesn't look right.",invalid_message:"Please write a little more.",unavailable:"Couldn't send that just now. Try again."};function sH({defaultEmail:e,onExit:t}){const[r,a]=ee.useState(""),[s,o]=ee.useState(e??""),[c,d]=ee.useState("form"),[h,f]=ee.useState(null),m=r.trim().length>0&&s.trim().length>0&&c!=="sending",p=async()=>{if(!m)return;d("sending"),f(null);const y=await NU(r.trim(),s.trim());if(y.ok){d("sent");return}d("form"),f(N_[y.error]??N_.unavailable)};return g.jsxs("div",{className:"mx-auto max-w-xl space-y-4",children:[g.jsxs("button",{onClick:()=>t("overview"),className:"cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white",children:[g.jsx(xp,{className:"h-4 w-4"}),"Back to results"]}),g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(pS,{className:"h-5 w-5 text-[#888]","aria-hidden":"true"}),g.jsx("h1",{className:"text-2xl font-semibold text-white",children:"Feedback & support"})]}),g.jsx("div",{className:"w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6",style:{border:"1px solid #2a2a2a"},children:c==="sent"?g.jsxs("div",{className:"flex items-start gap-3",children:[g.jsx(j_,{className:"mt-0.5 h-5 w-5 flex-shrink-0 text-emerald-400","aria-hidden":"true"}),g.jsxs("div",{className:"min-w-0",children:[g.jsx("p",{className:"text-sm font-semibold text-white",children:"Thanks, we got it."}),g.jsx("p",{className:"mt-1 text-xs text-[#888]",children:"We read every message. If it needs a reply, we'll reach out to the email you gave."}),g.jsx("button",{onClick:()=>{a(""),d("form")},className:"mt-4 cursor-pointer text-xs text-[#888] transition-colors hover:text-white",children:"Send more feedback"})]})]}):g.jsxs(g.Fragment,{children:[g.jsx("p",{className:"mb-4 text-xs text-[#666]",children:"Bugs, feature requests, or anything else. Tell us what's on your mind."}),h&&g.jsxs("div",{className:"mb-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2",children:[g.jsx(Hu,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-red-400","aria-hidden":"true"}),g.jsx("p",{className:"text-xs text-red-300",children:h})]}),g.jsxs("label",{className:"block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"Your feedback"}),g.jsx("textarea",{autoFocus:!0,value:r,maxLength:aH,onChange:y=>a(y.target.value),rows:5,placeholder:"What's working, what's not, what you'd love to see…",className:"w-full resize-y rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"})]}),g.jsxs("label",{className:"mt-4 block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"Your work email"}),g.jsx("input",{type:"email",value:s,onChange:y=>o(y.target.value),placeholder:"you@company.com",className:"w-full rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"})]}),g.jsx("button",{onClick:()=>void p(),disabled:!m,className:"mt-4 flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60",children:c==="sending"?"Sending…":"Send feedback"})]})})]})}function lH({text:e,children:t,className:r=""}){const[a,s]=ee.useState(!1);return g.jsxs("span",{className:`relative inline-flex ${r}`,onMouseEnter:()=>s(!0),onMouseLeave:()=>s(!1),onFocus:()=>s(!0),onBlur:()=>s(!1),children:[t,a&&g.jsx("span",{role:"tooltip",className:"pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-max max-w-[240px] -translate-x-1/2 rounded-md px-2.5 py-1.5 text-xs text-[#ddd] shadow-lg",style:{border:"1px solid #2a2a2a",background:"#0a0a0a"},children:e})]})}function gS({label:e,desc:t,slug:r,icon:a,surface:s}){return g.jsx(lH,{text:t,children:g.jsxs("a",{href:ha($u,r),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr(r,s),className:"group inline-flex items-center gap-2 rounded-lg border border-[#222] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-sm text-[#aaa] transition-colors hover:border-[#444] hover:text-white",children:[g.jsx(a,{className:"h-4 w-4 text-[#888] transition-colors group-hover:text-white","aria-hidden":"true"}),g.jsx("span",{children:e})]})})}const oH="Your findings stay on your machine. They're rendered here locally in your browser and never uploaded or stored by Strix.",S_=["critical","high","medium","low"],cH=500;function uH(){const[e,t]=ee.useState(null),[r,a]=ee.useState(null),[s,o]=ee.useState(null),[c,d]=ee.useState(null),[h,f]=ee.useState("overview"),[m,p]=ee.useState(null),[y,x]=ee.useState(null),[_,N]=ee.useState("report"),[S,w]=ee.useState(!1),[k,E]=ee.useState(!1),M=ee.useCallback(async()=>{try{p(await SU())}catch{}},[]),I=ee.useCallback(async()=>{try{x(await _U())}catch{}},[]);ee.useEffect(()=>{M(),I(),wU().then(C=>E(C.can_steer)).catch(()=>{})},[M,I]);const R=ee.useRef(!1);ee.useEffect(()=>{let C=!1,D;R.current=!1;const Y=()=>{D=setTimeout(L,cH)},L=async()=>{if(!C)try{const{summary:G,raw:q,finished:Q}=await lS(e);if(C)return;if(Q&&!R.current){R.current=!0;const te=await h_(e);C||a(te);return}const[J,W]=await Promise.all([cS(e).catch(()=>({agents:[],events:[]})),oS(G.runId,e).catch(()=>[])]);if(C)return;a(te=>({summary:G,raw:q,finished:Q,transcript:J,vulnerabilities:W,reportMarkdown:(te==null?void 0:te.reportMarkdown)??null})),Y()}catch(G){if(C)return;o(G instanceof Error?G.message:"Could not load run data."),Y()}};return(async()=>{try{const G=await h_(e);if(C)return;a(G),G.finished?R.current=!0:Y()}catch(G){if(C)return;o(G instanceof Error?G.message:"Could not load run data."),Y()}})(),()=>{C=!0,D&&clearTimeout(D)}},[e]);const U=ee.useMemo(()=>r?yU(r.vulnerabilities):null,[r]),B=(r==null?void 0:r.vulnerabilities.find(C=>C.id===c))??null,Z=(r==null?void 0:r.transcript.agents.length)??0,j=(m==null?void 0:m.verified)===!0,z=ee.useRef(!1);ee.useEffect(()=>{z.current=!1},[e]),ee.useEffect(()=>{z.current||!r||(r.finished?(z.current=!0,f("overview")):Z>0&&(z.current=!0,f("agents")))},[r,Z]);const V=ee.useCallback(C=>{z.current=!0,f(C)},[]),P=ee.useCallback(C=>{t(C),d(null),a(null),o(null),z.current=!1},[]),T=ee.useCallback((C,D)=>{jr("email_report",D),N("report"),w(C),V("email")},[V]),$=ee.useCallback(()=>T(!1,"sidebar"),[T]),O=ee.useCallback(()=>T(!0,"overview"),[T]),H=ee.useCallback(()=>{I(),V("history")},[I,V]),X=ee.useCallback(async()=>{await M(),await I()},[M,I]),K=ee.useCallback(async()=>{await kU(),await M(),await I()},[M,I]);return g.jsxs("div",{className:"min-h-screen bg-black text-white flex",children:[g.jsx(FU,{view:h,onSelectView:C=>{d(null),C==="history"?H():V(C)},issuesCount:(r==null?void 0:r.vulnerabilities.length)??0,agentCount:Z,runCount:(y==null?void 0:y.count)??0,finished:(r==null?void 0:r.finished)??!1,verified:j,email:(m==null?void 0:m.email)??null,onOpenEmail:$,onOpenHistory:H,onForget:()=>void K()}),g.jsxs("div",{className:"flex-1 min-w-0",children:[g.jsx("div",{className:"border-b border-[#222]",children:g.jsxs("div",{className:"max-w-[88rem] mx-auto px-3 sm:px-6 py-4 flex items-center gap-1.5",children:[g.jsxs("a",{href:ha("https://app.strix.ai","logo"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("logo","topbar"),className:"flex items-center gap-1.5 opacity-90 transition-opacity hover:opacity-100 lg:hidden",title:"Open Strix Cloud",children:[g.jsx("img",{src:"./logo.png",alt:"Strix",className:"w-10 h-8 object-cover"}),g.jsx("div",{className:"text-base text-white font-medium tracking-tight",children:"Strix"})]}),r&&g.jsx(fH,{finished:r.finished}),g.jsxs("div",{className:"ml-auto flex items-center gap-3",children:[j&&y&&!y.locked&&y.runs.length>0&&g.jsx(dH,{runs:y,activeRun:e,launchedName:bo((r==null?void 0:r.summary.targets[0])??null,(r==null?void 0:r.summary.runName)??(r==null?void 0:r.summary.runId)??"Current run"),onSelect:P}),g.jsxs("a",{href:ha($u,"run_in_cloud"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("run_in_cloud","topbar"),className:"inline-flex items-center gap-1 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity hover:opacity-90",children:["Run in the cloud",g.jsx(T_,{className:"w-3 h-3","aria-hidden":"true"})]})]})]})}),g.jsxs("div",{className:"max-w-[88rem] mx-auto px-3 sm:px-6 py-8 sm:py-12 space-y-6",children:[s&&!r&&h!=="history"&&h!=="email"&&g.jsxs("div",{className:"rounded-lg px-4 py-3 flex gap-3 items-start border border-red-500/30 bg-red-500/5",children:[g.jsx(Hu,{className:"w-5 h-5 flex-shrink-0 mt-0.5 text-red-400","aria-hidden":"true"}),g.jsx("p",{className:"text-sm text-red-300",children:s})]}),g.jsx("div",{className:"animate-page-in space-y-6",children:h==="email"?g.jsx(eH,{activeRun:e,auth:m,purpose:_,skipDisclosure:S,onAuthChanged:()=>{M(),I()},onExit:C=>f(C==="history"?"history":"overview")}):h==="feedback"?g.jsx(sH,{defaultEmail:(m==null?void 0:m.email)??null,onExit:C=>f(C)}):h==="history"?g.jsxs("div",{className:"space-y-4",children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(Vs,{className:"w-5 h-5 text-[#888]","aria-hidden":"true"}),g.jsx("h1",{className:"text-2xl font-semibold text-white",children:"Past runs"})]}),g.jsx(QU,{runs:y,activeRun:e,onSelectRun:P,onVerified:()=>void X()})]}):!r&&!s?g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-10 text-center",children:[g.jsx("div",{className:"w-6 h-6 mx-auto mb-3 rounded-full border-2 border-[#333] border-t-white animate-spin"}),g.jsx("p",{className:"text-sm text-[#888]",children:"Loading run data…"})]}):r&&U?g.jsxs(g.Fragment,{children:[g.jsx(mH,{summary:r.summary}),g.jsxs("div",{className:"flex gap-5 border-b border-[#2a2a2a] lg:hidden",children:[g.jsx(Bm,{active:h==="overview",onClick:()=>V("overview"),children:"Pentest Overview"}),g.jsxs(Bm,{active:h==="issues",onClick:()=>V("issues"),children:["Issues",r.vulnerabilities.length>0?` (${r.vulnerabilities.length})`:""]}),Z>0&&g.jsxs(Bm,{active:h==="agents",onClick:()=>V("agents"),children:["Agents (",Z,")"]})]}),h==="overview"?g.jsx(yH,{summary:r.summary,counts:U,total:r.vulnerabilities.length,reportMarkdown:r.reportMarkdown,raw:r.raw,finished:r.finished,onOpenEmail:O}):h==="agents"&&Z>0?g.jsx(vH,{run:r,canSteer:k}):B?g.jsxs("div",{className:"space-y-4",children:[g.jsxs("button",{onClick:()=>d(null),className:"cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] hover:text-white transition-colors",children:[g.jsx(xp,{className:"w-4 h-4"})," Back to all findings"]}),g.jsx(tD,{vulnerability:B})]}):g.jsx(pH,{vulnerabilities:r.vulnerabilities,finished:r.finished,onSelect:C=>d(C)})]}):null},`${e??"launched"}:${h}:${c??""}`)]})]}),g.jsx(iH,{message:oH})]})}function dH({runs:e,activeRun:t,launchedName:r,onSelect:a}){const[s,o]=ee.useState(!1),c=e.runs.find(h=>h.name===t),d=c?bo(c.target,c.name):r;return g.jsxs("div",{className:"relative",children:[g.jsxs("button",{onClick:()=>o(h=>!h),onBlur:()=>setTimeout(()=>o(!1),150),"aria-label":"Switch pentest",className:"flex items-center gap-2 rounded-lg border border-[#3a3a3a] bg-[rgba(255,255,255,0.05)] px-3 py-2 text-sm text-white transition-colors hover:border-[#555] hover:bg-[rgba(255,255,255,0.09)]",children:[g.jsx(Vs,{className:"h-4 w-4 flex-shrink-0 text-[#888]","aria-hidden":"true"}),g.jsx("span",{className:"flex-shrink-0 text-[#888]",children:"Pentest"}),g.jsx("span",{className:"max-w-[260px] truncate font-medium",children:d}),g.jsx(ho,{className:"h-4 w-4 flex-shrink-0 text-[#aaa]","aria-hidden":"true"})]}),s&&g.jsxs("div",{className:"absolute right-0 z-50 mt-2 max-h-96 w-96 overflow-y-auto rounded-xl py-1.5 shadow-2xl",style:{border:"1px solid #3a3a3a",background:"#0a0a0a"},children:[g.jsx("div",{className:"border-b border-[#222] px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[#666]",children:"Switch pentest"}),e.runs.map(h=>{const f=h.name===t;return g.jsxs("button",{onMouseDown:()=>a(h.name),className:`flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors hover:bg-[rgba(255,255,255,0.06)] ${f?"bg-[rgba(255,255,255,0.04)] text-white":"text-[#aaa]"}`,children:[g.jsxs("span",{className:"min-w-0 flex-1",children:[g.jsx("span",{className:"block truncate font-medium",children:bo(h.target,h.name)}),h.target&&g.jsx("span",{className:"block truncate font-mono text-xs text-[#666]",children:h.target})]}),f&&g.jsx("span",{className:"h-2 w-2 flex-shrink-0 rounded-full bg-emerald-400"})]},h.name)})]})]})}function fH({finished:e}){return e?g.jsxs("span",{className:"ml-3 inline-flex items-center gap-1.5 text-xs text-[#888]",children:[g.jsx("span",{className:"w-1.5 h-1.5 rounded-full bg-[#555]"}),"Complete"]}):g.jsxs("span",{className:"ml-3 inline-flex items-center gap-1.5 text-xs text-emerald-400",children:[g.jsxs("span",{className:"relative flex h-1.5 w-1.5",children:[g.jsx("span",{className:"absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75 animate-ping"}),g.jsx("span",{className:"relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-400"})]}),"Live"]})}function hH(e){if(e==null)return null;if(e<60)return`${e}s`;const t=Math.floor(e/60);return t<60?`${t}m`:`${Math.floor(t/60)}h ${t%60}m`}function mH({summary:e}){const t=hH(e.durationSeconds);return g.jsxs("div",{children:[g.jsx("h1",{className:"text-2xl font-semibold text-white",children:bo(e.targets[0]??null,e.runName??e.runId??"Pentest results")}),g.jsxs("div",{className:"mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[#888]",children:[e.targets.length>0&&g.jsx("span",{className:"font-mono text-[#aaa]",children:e.targets.join(", ")}),e.scanMode&&g.jsx(Im,{label:e.scanMode}),t&&g.jsx(Im,{label:t}),e.status&&g.jsx(Im,{label:e.status})]})]})}function Im({label:e}){return g.jsxs(g.Fragment,{children:[g.jsx("span",{className:"text-[#333]",children:"·"}),g.jsx("span",{className:"capitalize",children:e})]})}function pH({vulnerabilities:e,finished:t,onSelect:r}){const a=[...e].sort((s,o)=>S_.indexOf(s.severity)-S_.indexOf(o.severity));return a.length===0?g.jsxs("div",{className:"space-y-4",children:[g.jsx("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]",children:t?"No findings in this run.":"No findings yet. The pentest is still running…"}),t&&g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:[g.jsx("p",{className:"text-sm font-medium text-white",children:"Stay ahead of new exposures"}),g.jsx("p",{className:"mt-0.5 mb-3 text-xs text-[#666]",children:"Attack surface monitoring catches new exposures for your org over time."}),g.jsx(gS,{label:"Attack surface monitoring",desc:"Continuous coverage for your whole org.",slug:"asm",surface:"empty_state",icon:XC})]})]}):g.jsx("div",{className:"space-y-2",children:a.map(s=>g.jsxs("button",{onClick:()=>r(s.id),className:"animate-card-in cursor-pointer w-full text-left rounded-lg border border-[#222] hover:border-[#444] bg-[rgba(255,255,255,0.02)] px-4 py-3 transition-colors flex items-center gap-3",children:[g.jsx("span",{className:`w-2.5 h-2.5 rounded-full flex-shrink-0 ${_p(s.severity)}`,"aria-hidden":"true"}),g.jsxs("span",{className:"flex-1 min-w-0",children:[g.jsx("span",{className:"block text-sm font-medium text-white truncate",children:s.title}),s.target&&g.jsx("span",{className:"block text-xs text-[#666] font-mono truncate",children:s.target})]}),g.jsx("span",{className:`text-xs font-semibold px-2 py-0.5 rounded-full border capitalize ${U_[s.severity]}`,children:s.severity})]},s.id))})}function gH(e){return e.replace(/^\s*#{1,6}[ \t]+.*(?:\r?\n)+/,"").trimStart()}function bH(e){const t=[];let r=null;for(const a of e.split(` `)){const s=a.match(/^#{1,6}\s+(.*)$/);if(s){const o=s[1].trim().toLowerCase();if(o===r)continue;r=o}else a.trim()!==""&&(r=null);t.push(a)}return t.join(` -`)}function gH({onOpenEmail:e}){return g.jsx("button",{onClick:e,className:"group w-full cursor-pointer rounded-xl border border-emerald-500/25 bg-emerald-500/[0.06] p-4 text-left transition-colors hover:border-emerald-500/40",children:g.jsxs("div",{className:"flex items-center gap-3",children:[g.jsx("div",{className:"flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg",style:{border:"1px solid rgba(16,185,129,0.3)",background:"rgba(16,185,129,0.08)"},children:g.jsx(yp,{className:"h-4 w-4 text-emerald-400","aria-hidden":"true"})}),g.jsxs("div",{className:"min-w-0 flex-1",children:[g.jsx("p",{className:"text-sm font-semibold text-white",children:"Email an encrypted PDF report of this run"}),g.jsx("p",{className:"mt-0.5 text-xs text-[#888]",children:"Encrypted with a key only you can see, email verified with a one-time code before sending."})]}),g.jsx("span",{className:"flex-shrink-0 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity group-hover:opacity-90",children:"Export report to PDF"})]})})}function bH({summary:e,counts:t,total:r,reportMarkdown:a,raw:s,finished:o,onOpenEmail:c}){const d=[["Executive Summary",e.executiveSummary],["Technical Analysis",e.technicalAnalysis],["Methodology",e.methodology],["Recommendations",e.recommendations]].filter(([,h])=>!!h).map(([h,f])=>({title:h,content:mH(f)}));return g.jsxs("div",{className:"space-y-6",children:[g.jsx("div",{className:"animate-card-in",children:g.jsx(tH,{raw:s,durationSeconds:e.durationSeconds})}),r>0&&g.jsx("div",{className:"animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:g.jsx(nD,{findings:{total:r,...t}})}),o&&g.jsx("div",{className:"animate-card-in",children:g.jsx(gH,{onOpenEmail:c})}),d.length>0?g.jsx("div",{className:"animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 space-y-8",children:d.map(h=>g.jsx(oa,{title:h.title,content:h.content},h.title))}):a?g.jsx("div",{className:"animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:g.jsx(oa,{content:pH(a)})}):r===0&&g.jsx("p",{className:"text-sm text-[#888]",children:"No summary available for this run yet."})]})}function Bm({active:e,onClick:t,children:r}){return g.jsxs("button",{onClick:t,className:`cursor-pointer relative pb-2.5 text-sm font-semibold transition-colors ${e?"text-white":"text-[#666] hover:text-white"}`,children:[r,e&&g.jsx("span",{className:"absolute bottom-0 inset-x-0 h-0.5 bg-white rounded-full"})]})}function xH({run:e,canSteer:t}){const{agents:r,events:a}=e.transcript,s=ee.useMemo(()=>lU(r,a),[r,a]),[o,c]=ee.useState(null),d=o?r.find(f=>f.id===o)??null:null,h=t&&!e.finished;return g.jsxs("div",{className:"space-y-5",children:[g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(Ao,{className:"w-4 h-4 text-[#888]","aria-hidden":"true"}),g.jsx("h2",{className:"text-sm font-semibold text-white",children:"Agent graph"}),g.jsxs("span",{className:"text-xs text-[#666]",children:[r.length," agent",r.length===1?"":"s"]})]}),g.jsx("p",{className:"mt-1 mb-4 text-xs text-[#666]",children:"Click an agent to open its full transcript."}),g.jsx("div",{className:"h-[480px] rounded-lg border border-[#1a1a1a] overflow-hidden",children:g.jsx(GB,{agents:s,selectedAgentId:o,onSelectAgent:f=>c(f),eventsLoaded:!0,eventsEmpty:s.size===0,scanCompleted:e.finished})})]}),h&&g.jsx(fS,{agents:r}),g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:[g.jsx("p",{className:"text-sm font-semibold text-white",children:"Run this pentest with more depth"}),g.jsx("p",{className:"mt-0.5 text-xs text-[#666]",children:"Re-run this pentest on managed infra in the cloud."}),g.jsx("div",{className:"mt-3 flex flex-wrap gap-2.5",children:g.jsx(gS,{label:"Re-run in Strix Pro with more depth",desc:"Run this pentest on managed infra with more depth.",slug:"live_scan",surface:"agents",icon:WC})})]}),g.jsx(TU,{open:d!==null,agent:d,events:a,steerable:h,onClose:()=>c(null)})]})}Ck.createRoot(document.getElementById("root")).render(g.jsx(ee.StrictMode,{children:g.jsx(oH,{})})); +`)}function xH({onOpenEmail:e}){return g.jsx("button",{onClick:e,className:"group w-full cursor-pointer rounded-xl border border-emerald-500/25 bg-emerald-500/[0.06] p-4 text-left transition-colors hover:border-emerald-500/40",children:g.jsxs("div",{className:"flex items-center gap-3",children:[g.jsx("div",{className:"flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg",style:{border:"1px solid rgba(16,185,129,0.3)",background:"rgba(16,185,129,0.08)"},children:g.jsx(yp,{className:"h-4 w-4 text-emerald-400","aria-hidden":"true"})}),g.jsxs("div",{className:"min-w-0 flex-1",children:[g.jsx("p",{className:"text-sm font-semibold text-white",children:"Email an encrypted PDF report of this run"}),g.jsx("p",{className:"mt-0.5 text-xs text-[#888]",children:"Encrypted with a key only you can see, email verified with a one-time code before sending."})]}),g.jsx("span",{className:"flex-shrink-0 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity group-hover:opacity-90",children:"Export report to PDF"})]})})}function yH({summary:e,counts:t,total:r,reportMarkdown:a,raw:s,finished:o,onOpenEmail:c}){const d=[["Executive Summary",e.executiveSummary],["Technical Analysis",e.technicalAnalysis],["Methodology",e.methodology],["Recommendations",e.recommendations]].filter(([,h])=>!!h).map(([h,f])=>({title:h,content:gH(f)}));return g.jsxs("div",{className:"space-y-6",children:[g.jsx("div",{className:"animate-card-in",children:g.jsx(rH,{raw:s,durationSeconds:e.durationSeconds})}),r>0&&g.jsx("div",{className:"animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:g.jsx(nD,{findings:{total:r,...t}})}),o&&g.jsx("div",{className:"animate-card-in",children:g.jsx(xH,{onOpenEmail:c})}),d.length>0?g.jsx("div",{className:"animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 space-y-8",children:d.map(h=>g.jsx(oa,{title:h.title,content:h.content},h.title))}):a?g.jsx("div",{className:"animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:g.jsx(oa,{content:bH(a)})}):r===0&&g.jsx("p",{className:"text-sm text-[#888]",children:"No summary available for this run yet."})]})}function Bm({active:e,onClick:t,children:r}){return g.jsxs("button",{onClick:t,className:`cursor-pointer relative pb-2.5 text-sm font-semibold transition-colors ${e?"text-white":"text-[#666] hover:text-white"}`,children:[r,e&&g.jsx("span",{className:"absolute bottom-0 inset-x-0 h-0.5 bg-white rounded-full"})]})}function vH({run:e,canSteer:t}){const{agents:r,events:a}=e.transcript,s=ee.useMemo(()=>cU(r,a),[r,a]),[o,c]=ee.useState(null),d=o?r.find(f=>f.id===o)??null:null,h=t&&!e.finished;return g.jsxs("div",{className:"space-y-5",children:[g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(Ao,{className:"w-4 h-4 text-[#888]","aria-hidden":"true"}),g.jsx("h2",{className:"text-sm font-semibold text-white",children:"Agent graph"}),g.jsxs("span",{className:"text-xs text-[#666]",children:[r.length," agent",r.length===1?"":"s"]})]}),g.jsx("p",{className:"mt-1 mb-4 text-xs text-[#666]",children:"Click an agent to open its full transcript."}),g.jsx("div",{className:"h-[480px] rounded-lg border border-[#1a1a1a] overflow-hidden",children:g.jsx(GB,{agents:s,selectedAgentId:o,onSelectAgent:f=>c(f),eventsLoaded:!0,eventsEmpty:s.size===0,scanCompleted:e.finished})})]}),h&&g.jsx(fS,{agents:r}),g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:[g.jsx("p",{className:"text-sm font-semibold text-white",children:"Run this pentest with more depth"}),g.jsx("p",{className:"mt-0.5 text-xs text-[#666]",children:"Re-run this pentest on managed infra in the cloud."}),g.jsx("div",{className:"mt-3 flex flex-wrap gap-2.5",children:g.jsx(gS,{label:"Re-run in Strix Pro with more depth",desc:"Run this pentest on managed infra with more depth.",slug:"live_scan",surface:"agents",icon:WC})})]}),g.jsx(MU,{open:d!==null,agent:d,events:a,steerable:h,onClose:()=>c(null)})]})}Ck.createRoot(document.getElementById("root")).render(g.jsx(ee.StrictMode,{children:g.jsx(uH,{})})); diff --git a/strix/interface/viewer/static/assets/index-DKbLYAbP.css b/strix/interface/viewer/static/assets/index-DKbLYAbP.css new file mode 100644 index 00000000..13a934ac --- /dev/null +++ b/strix/interface/viewer/static/assets/index-DKbLYAbP.css @@ -0,0 +1,10 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*! + Theme: GitHub Dark + Description: Dark theme as seen on github.com + Author: github.com + Maintainer: @Hirse + Updated: 2021-05-15 + + Outdated base version: https://github.com/primer/github-syntax-dark + Current colors taken from GitHub's CSS +*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id{color:#79c0ff}.hljs-regexp,.hljs-string,.hljs-meta .hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-comment,.hljs-code,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}.react-flow{direction:ltr;--xy-edge-stroke-default: #b1b1b7;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #555;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(255, 255, 255, .5);--xy-minimap-background-color-default: #fff;--xy-minimap-mask-background-color-default: rgba(240, 240, 240, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #e2e2e2;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: transparent;--xy-background-pattern-dots-color-default: #91919a;--xy-background-pattern-lines-color-default: #eee;--xy-background-pattern-cross-color-default: #e2e2e2;background-color:var(--xy-background-color, var(--xy-background-color-default));--xy-node-color-default: inherit;--xy-node-border-default: 1px solid #1a192b;--xy-node-background-color-default: #fff;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(0, 0, 0, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #1a192b;--xy-node-border-radius-default: 3px;--xy-handle-background-color-default: #1a192b;--xy-handle-border-color-default: #fff;--xy-selection-background-color-default: rgba(0, 89, 220, .08);--xy-selection-border-default: 1px dotted rgba(0, 89, 220, .8);--xy-controls-button-background-color-default: #fefefe;--xy-controls-button-background-color-hover-default: #f4f4f4;--xy-controls-button-color-default: inherit;--xy-controls-button-color-hover-default: inherit;--xy-controls-button-border-color-default: #eee;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #ffffff;--xy-edge-label-color-default: inherit;--xy-resize-background-color-default: #3367d9}.react-flow.dark{--xy-edge-stroke-default: #3e3e3e;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #727272;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(150, 150, 150, .25);--xy-minimap-background-color-default: #141414;--xy-minimap-mask-background-color-default: rgba(60, 60, 60, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #2b2b2b;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: #141414;--xy-background-pattern-dots-color-default: #555;--xy-background-pattern-lines-color-default: #333;--xy-background-pattern-cross-color-default: #333;--xy-node-color-default: #f8f8f8;--xy-node-border-default: 1px solid #3c3c3c;--xy-node-background-color-default: #1e1e1e;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(255, 255, 255, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #999;--xy-handle-background-color-default: #bebebe;--xy-handle-border-color-default: #1e1e1e;--xy-selection-background-color-default: rgba(200, 200, 220, .08);--xy-selection-border-default: 1px dotted rgba(200, 200, 220, .8);--xy-controls-button-background-color-default: #2b2b2b;--xy-controls-button-background-color-hover-default: #3e3e3e;--xy-controls-button-color-default: #f8f8f8;--xy-controls-button-color-hover-default: #fff;--xy-controls-button-border-color-default: #5b5b5b;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #141414;--xy-edge-label-color-default: #f8f8f8}.react-flow__background{background-color:var(--xy-background-color-props, var(--xy-background-color, var(--xy-background-color-default)));pointer-events:none;z-index:-1}.react-flow__container{position:absolute;width:100%;height:100%;top:0;left:0}.react-flow__pane{z-index:1;touch-action:none}.react-flow__pane.draggable{cursor:grab}.react-flow__pane.dragging{cursor:grabbing}.react-flow__pane.selection{cursor:pointer}.react-flow__viewport{transform-origin:0 0;z-index:2;pointer-events:none}.react-flow__renderer{z-index:4}.react-flow__selection{z-index:6}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible{outline:none}.react-flow__edge-path{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default));stroke-width:var(--xy-edge-stroke-width, var(--xy-edge-stroke-width-default));fill:none}.react-flow__connection-path{stroke:var(--xy-connectionline-stroke, var(--xy-connectionline-stroke-default));stroke-width:var(--xy-connectionline-stroke-width, var(--xy-connectionline-stroke-width-default));fill:none}.react-flow .react-flow__edges{position:absolute}.react-flow .react-flow__edges svg{overflow:visible;position:absolute;pointer-events:none}.react-flow__edge{pointer-events:visibleStroke}.react-flow__edge.selectable{cursor:pointer}.react-flow__edge.animated path{stroke-dasharray:5;animation:dashdraw .5s linear infinite}.react-flow__edge.animated path.react-flow__edge-interaction{stroke-dasharray:none;animation:none}.react-flow__edge.inactive{pointer-events:none}.react-flow__edge.selected,.react-flow__edge:focus,.react-flow__edge:focus-visible{outline:none}.react-flow__edge.selected .react-flow__edge-path,.react-flow__edge.selectable:focus .react-flow__edge-path,.react-flow__edge.selectable:focus-visible .react-flow__edge-path{stroke:var(--xy-edge-stroke-selected, var(--xy-edge-stroke-selected-default))}.react-flow__edge-textwrapper{pointer-events:all}.react-flow__edge .react-flow__edge-text{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__arrowhead polyline{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__arrowhead polyline.arrowclosed{fill:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__connection{pointer-events:none}.react-flow__connection .animated{stroke-dasharray:5;animation:dashdraw .5s linear infinite}svg.react-flow__connectionline{z-index:1001;overflow:visible;position:absolute}.react-flow__nodes{pointer-events:none;transform-origin:0 0}.react-flow__node{position:absolute;-webkit-user-select:none;-moz-user-select:none;user-select:none;pointer-events:all;transform-origin:0 0;box-sizing:border-box;cursor:default}.react-flow__node.selectable{cursor:pointer}.react-flow__node.draggable{cursor:grab;pointer-events:all}.react-flow__node.draggable.dragging{cursor:grabbing}.react-flow__nodesselection{z-index:3;transform-origin:left top;pointer-events:none}.react-flow__nodesselection-rect{position:absolute;pointer-events:all;cursor:grab}.react-flow__handle{position:absolute;pointer-events:none;min-width:5px;min-height:5px;width:6px;height:6px;background-color:var(--xy-handle-background-color, var(--xy-handle-background-color-default));border:1px solid var(--xy-handle-border-color, var(--xy-handle-border-color-default));border-radius:100%}.react-flow__handle.connectingfrom{pointer-events:all}.react-flow__handle.connectionindicator{pointer-events:all;cursor:crosshair}.react-flow__handle-bottom{top:auto;left:50%;bottom:0;transform:translate(-50%,50%)}.react-flow__handle-top{top:0;left:50%;transform:translate(-50%,-50%)}.react-flow__handle-left{top:50%;left:0;transform:translate(-50%,-50%)}.react-flow__handle-right{top:50%;right:0;transform:translate(50%,-50%)}.react-flow__edgeupdater{cursor:move;pointer-events:all}.react-flow__pane.selection .react-flow__panel{pointer-events:none}.react-flow__panel{position:absolute;z-index:5;margin:15px}.react-flow__panel.top{top:0}.react-flow__panel.bottom{bottom:0}.react-flow__panel.top.center,.react-flow__panel.bottom.center{left:50%;transform:translate(-15px) translate(-50%)}.react-flow__panel.left{left:0}.react-flow__panel.right{right:0}.react-flow__panel.left.center,.react-flow__panel.right.center{top:50%;transform:translateY(-15px) translateY(-50%)}.react-flow__attribution{font-size:10px;background:var(--xy-attribution-background-color, var(--xy-attribution-background-color-default));padding:2px 3px;margin:0}.react-flow__attribution a{text-decoration:none;color:#999}@keyframes dashdraw{0%{stroke-dashoffset:10}}.react-flow__edgelabel-renderer{position:absolute;width:100%;height:100%;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;left:0;top:0}.react-flow__viewport-portal{position:absolute;width:100%;height:100%;left:0;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__minimap{background:var( --xy-minimap-background-color-props, var(--xy-minimap-background-color, var(--xy-minimap-background-color-default)) )}.react-flow__minimap-svg{display:block}.react-flow__minimap-mask{fill:var( --xy-minimap-mask-background-color-props, var(--xy-minimap-mask-background-color, var(--xy-minimap-mask-background-color-default)) );stroke:var( --xy-minimap-mask-stroke-color-props, var(--xy-minimap-mask-stroke-color, var(--xy-minimap-mask-stroke-color-default)) );stroke-width:var( --xy-minimap-mask-stroke-width-props, var(--xy-minimap-mask-stroke-width, var(--xy-minimap-mask-stroke-width-default)) )}.react-flow__minimap-node{fill:var( --xy-minimap-node-background-color-props, var(--xy-minimap-node-background-color, var(--xy-minimap-node-background-color-default)) );stroke:var( --xy-minimap-node-stroke-color-props, var(--xy-minimap-node-stroke-color, var(--xy-minimap-node-stroke-color-default)) );stroke-width:var( --xy-minimap-node-stroke-width-props, var(--xy-minimap-node-stroke-width, var(--xy-minimap-node-stroke-width-default)) )}.react-flow__background-pattern.dots{fill:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-dots-color-default)) )}.react-flow__background-pattern.lines{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-lines-color-default)) )}.react-flow__background-pattern.cross{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-cross-color-default)) )}.react-flow__controls{display:flex;flex-direction:column;box-shadow:var(--xy-controls-box-shadow, var(--xy-controls-box-shadow-default))}.react-flow__controls.horizontal{flex-direction:row}.react-flow__controls-button{display:flex;justify-content:center;align-items:center;height:26px;width:26px;padding:4px;border:none;background:var(--xy-controls-button-background-color, var(--xy-controls-button-background-color-default));border-bottom:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) );color:var( --xy-controls-button-color-props, var(--xy-controls-button-color, var(--xy-controls-button-color-default)) );cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__controls-button svg{width:100%;max-width:12px;max-height:12px;fill:currentColor}.react-flow__edge.updating .react-flow__edge-path{stroke:#777}.react-flow__edge-text{font-size:10px}.react-flow__node.selectable:focus,.react-flow__node.selectable:focus-visible{outline:none}.react-flow__node-input,.react-flow__node-default,.react-flow__node-output,.react-flow__node-group{padding:10px;border-radius:var(--xy-node-border-radius, var(--xy-node-border-radius-default));width:150px;font-size:12px;color:var(--xy-node-color, var(--xy-node-color-default));text-align:center;border:var(--xy-node-border, var(--xy-node-border-default));background-color:var(--xy-node-background-color, var(--xy-node-background-color-default))}.react-flow__node-input.selectable:hover,.react-flow__node-default.selectable:hover,.react-flow__node-output.selectable:hover,.react-flow__node-group.selectable:hover{box-shadow:var(--xy-node-boxshadow-hover, var(--xy-node-boxshadow-hover-default))}.react-flow__node-input.selectable.selected,.react-flow__node-input.selectable:focus,.react-flow__node-input.selectable:focus-visible,.react-flow__node-default.selectable.selected,.react-flow__node-default.selectable:focus,.react-flow__node-default.selectable:focus-visible,.react-flow__node-output.selectable.selected,.react-flow__node-output.selectable:focus,.react-flow__node-output.selectable:focus-visible,.react-flow__node-group.selectable.selected,.react-flow__node-group.selectable:focus,.react-flow__node-group.selectable:focus-visible{box-shadow:var(--xy-node-boxshadow-selected, var(--xy-node-boxshadow-selected-default))}.react-flow__node-group{background-color:var(--xy-node-group-background-color, var(--xy-node-group-background-color-default))}.react-flow__nodesselection-rect,.react-flow__selection{background:var(--xy-selection-background-color, var(--xy-selection-background-color-default));border:var(--xy-selection-border, var(--xy-selection-border-default))}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible,.react-flow__selection:focus,.react-flow__selection:focus-visible{outline:none}.react-flow__controls-button:hover{background:var( --xy-controls-button-background-color-hover-props, var(--xy-controls-button-background-color-hover, var(--xy-controls-button-background-color-hover-default)) );color:var( --xy-controls-button-color-hover-props, var(--xy-controls-button-color-hover, var(--xy-controls-button-color-hover-default)) )}.react-flow__controls-button:disabled{pointer-events:none}.react-flow__controls-button:disabled svg{fill-opacity:.4}.react-flow__controls-button:last-child{border-bottom:none}.react-flow__controls.horizontal .react-flow__controls-button{border-bottom:none;border-right:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) )}.react-flow__controls.horizontal .react-flow__controls-button:last-child{border-right:none}.react-flow__resize-control{position:absolute}.react-flow__resize-control.left,.react-flow__resize-control.right{cursor:ew-resize}.react-flow__resize-control.top,.react-flow__resize-control.bottom{cursor:ns-resize}.react-flow__resize-control.top.left,.react-flow__resize-control.bottom.right{cursor:nwse-resize}.react-flow__resize-control.bottom.left,.react-flow__resize-control.top.right{cursor:nesw-resize}.react-flow__resize-control.handle{width:5px;height:5px;border:1px solid #fff;border-radius:1px;background-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));translate:-50% -50%}.react-flow__resize-control.handle.left{left:0;top:50%}.react-flow__resize-control.handle.right{left:100%;top:50%}.react-flow__resize-control.handle.top{left:50%;top:0}.react-flow__resize-control.handle.bottom{left:50%;top:100%}.react-flow__resize-control.handle.top.left,.react-flow__resize-control.handle.bottom.left{left:0}.react-flow__resize-control.handle.top.right,.react-flow__resize-control.handle.bottom.right{left:100%}.react-flow__resize-control.line{border-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));border-width:0;border-style:solid}.react-flow__resize-control.line.left,.react-flow__resize-control.line.right{width:1px;transform:translate(-50%);top:0;height:100%}.react-flow__resize-control.line.left{left:0;border-left-width:1px}.react-flow__resize-control.line.right{left:100%;border-right-width:1px}.react-flow__resize-control.line.top,.react-flow__resize-control.line.bottom{height:1px;transform:translateY(-50%);left:0;width:100%}.react-flow__resize-control.line.top{top:0;border-top-width:1px}.react-flow__resize-control.line.bottom{border-bottom-width:1px;top:100%}.react-flow__edge-textbg{fill:var(--xy-edge-label-background-color, var(--xy-edge-label-background-color-default))}.react-flow__edge-text{fill:var(--xy-edge-label-color, var(--xy-edge-label-color-default))}/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-amber-400:oklch(82.8% .189 84.429);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-cyan-400:oklch(78.9% .154 211.53);--color-cyan-500:oklch(71.5% .143 215.221);--color-sky-400:oklch(74.6% .16 232.661);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-neutral-200:oklch(92.2% 0 none);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-xl:36rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-snug:1.375;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-geist-sans);--default-mono-font-family:var(--font-geist-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{top:0;right:0;bottom:0;left:0}.inset-x-0{inset-inline:0}.top-0{top:0}.top-2{top:calc(var(--spacing) * 2)}.top-4{top:calc(var(--spacing) * 4)}.right-0{right:0}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.bottom-0{bottom:0}.bottom-3{bottom:calc(var(--spacing) * 3)}.bottom-full{bottom:100%}.left-0{left:0}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-3{left:calc(var(--spacing) * 3)}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.-mx-2{margin-inline:calc(var(--spacing) * -2)}.mx-0{margin-inline:0}.mx-auto{margin-inline:auto}.my-1{margin-block:var(--spacing)}.my-4{margin-block:calc(var(--spacing) * 4)}.-mt-0\.5{margin-top:calc(var(--spacing) * -.5)}.mt-0{margin-top:0}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-\[1px\]{margin-top:1px}.-mr-0\.5{margin-right:calc(var(--spacing) * -.5)}.mr-1{margin-right:var(--spacing)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-2\.5{margin-bottom:calc(var(--spacing) * 2.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-auto{margin-left:auto}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.\!h-1\.5{height:calc(var(--spacing) * 1.5)!important}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-\[30px\]{height:30px}.h-\[60vh\]{height:60vh}.h-\[72px\]{height:72px}.h-\[480px\]{height:480px}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[160px\]{max-height:160px}.max-h-\[400px\]{max-height:400px}.max-h-\[1200px\]{max-height:1200px}.min-h-screen{min-height:100vh}.\!w-1\.5{width:calc(var(--spacing) * 1.5)!important}.w-0\.5{width:calc(var(--spacing) * .5)}.w-1{width:var(--spacing)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-28{width:calc(var(--spacing) * 28)}.w-96{width:calc(var(--spacing) * 96)}.w-\[1px\]{width:1px}.w-\[30px\]{width:30px}.w-\[180px\]{width:180px}.w-\[260px\]{width:260px}.w-\[calc\(100vw-4rem\)\]{width:calc(100vw - 4rem)}.w-full{width:100%}.w-max{width:max-content}.w-px{width:1px}.max-w-6xl{max-width:var(--container-6xl)}.max-w-\[75\%\]{max-width:75%}.max-w-\[80\%\]{max-width:80%}.max-w-\[88rem\]{max-width:88rem}.max-w-\[140px\]{max-width:140px}.max-w-\[240px\]{max-width:240px}.max-w-\[260px\]{max-width:260px}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-\[80px\]{min-width:80px}.min-w-\[112px\]{min-width:112px}.min-w-\[160px\]{min-width:160px}.flex-1{flex:1}.flex-none{flex:none}.flex-shrink-0,.shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}.origin-left{transform-origin:0}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.-rotate-90{rotate:-90deg}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-pointer{cursor:pointer}.resize{resize:both}.resize-none{resize:none}.resize-y{resize:vertical}.scrollbar-thin{scrollbar-width:thin}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-\[7rem_1fr\]{grid-template-columns:7rem 1fr}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.place-content-center{place-content:center}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-10{gap:calc(var(--spacing) * 10)}.gap-px{gap:1px}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-1{row-gap:var(--spacing)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}.gap-y-6{row-gap:calc(var(--spacing) * 6)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-clip{overflow-x:clip}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.\!border-none{--tw-border-style:none!important;border-style:none!important}.\!border-\[\#222\]{border-color:#222!important}.border-\[\#1a1a1a\]{border-color:#1a1a1a}.border-\[\#2a2a2a\]{border-color:#2a2a2a}.border-\[\#3a3a3a\]{border-color:#3a3a3a}.border-\[\#22c55e\]\/40{border-color:#22c55e66}.border-\[\#222\]{border-color:#222}.border-\[\#333\]{border-color:#333}.border-\[\#444\]{border-color:#444}.border-\[\#191919\]{border-color:#191919}.border-\[rgba\(255\,255\,255\,0\.08\)\]{border-color:#ffffff14}.border-blue-500\/20{border-color:#3080ff33}@supports (color:color-mix(in lab,red,red)){.border-blue-500\/20{border-color:color-mix(in oklab,var(--color-blue-500) 20%,transparent)}}.border-blue-500\/30{border-color:#3080ff4d}@supports (color:color-mix(in lab,red,red)){.border-blue-500\/30{border-color:color-mix(in oklab,var(--color-blue-500) 30%,transparent)}}.border-blue-500\/40{border-color:#3080ff66}@supports (color:color-mix(in lab,red,red)){.border-blue-500\/40{border-color:color-mix(in oklab,var(--color-blue-500) 40%,transparent)}}.border-emerald-500\/20{border-color:#00bb7f33}@supports (color:color-mix(in lab,red,red)){.border-emerald-500\/20{border-color:color-mix(in oklab,var(--color-emerald-500) 20%,transparent)}}.border-emerald-500\/25{border-color:#00bb7f40}@supports (color:color-mix(in lab,red,red)){.border-emerald-500\/25{border-color:color-mix(in oklab,var(--color-emerald-500) 25%,transparent)}}.border-emerald-500\/30{border-color:#00bb7f4d}@supports (color:color-mix(in lab,red,red)){.border-emerald-500\/30{border-color:color-mix(in oklab,var(--color-emerald-500) 30%,transparent)}}.border-gray-500\/20{border-color:#6a728233}@supports (color:color-mix(in lab,red,red)){.border-gray-500\/20{border-color:color-mix(in oklab,var(--color-gray-500) 20%,transparent)}}.border-orange-500\/20{border-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.border-orange-500\/20{border-color:color-mix(in oklab,var(--color-orange-500) 20%,transparent)}}.border-orange-500\/30{border-color:#fe6e004d}@supports (color:color-mix(in lab,red,red)){.border-orange-500\/30{border-color:color-mix(in oklab,var(--color-orange-500) 30%,transparent)}}.border-purple-500\/20{border-color:#ac4bff33}@supports (color:color-mix(in lab,red,red)){.border-purple-500\/20{border-color:color-mix(in oklab,var(--color-purple-500) 20%,transparent)}}.border-red-500\/20{border-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.border-red-500\/20{border-color:color-mix(in oklab,var(--color-red-500) 20%,transparent)}}.border-red-500\/25{border-color:#fb2c3640}@supports (color:color-mix(in lab,red,red)){.border-red-500\/25{border-color:color-mix(in oklab,var(--color-red-500) 25%,transparent)}}.border-red-500\/30{border-color:#fb2c364d}@supports (color:color-mix(in lab,red,red)){.border-red-500\/30{border-color:color-mix(in oklab,var(--color-red-500) 30%,transparent)}}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab,red,red)){.border-white\/10{border-color:color-mix(in oklab,var(--color-white) 10%,transparent)}}.border-white\/30{border-color:#ffffff4d}@supports (color:color-mix(in lab,red,red)){.border-white\/30{border-color:color-mix(in oklab,var(--color-white) 30%,transparent)}}.border-white\/\[0\.06\]{border-color:#ffffff0f}@supports (color:color-mix(in lab,red,red)){.border-white\/\[0\.06\]{border-color:color-mix(in oklab,var(--color-white) 6%,transparent)}}.border-white\/\[0\.08\]{border-color:#ffffff14}@supports (color:color-mix(in lab,red,red)){.border-white\/\[0\.08\]{border-color:color-mix(in oklab,var(--color-white) 8%,transparent)}}.border-white\/\[0\.18\]{border-color:#ffffff2e}@supports (color:color-mix(in lab,red,red)){.border-white\/\[0\.18\]{border-color:color-mix(in oklab,var(--color-white) 18%,transparent)}}.border-yellow-500\/20{border-color:#edb20033}@supports (color:color-mix(in lab,red,red)){.border-yellow-500\/20{border-color:color-mix(in oklab,var(--color-yellow-500) 20%,transparent)}}.border-yellow-500\/25{border-color:#edb20040}@supports (color:color-mix(in lab,red,red)){.border-yellow-500\/25{border-color:color-mix(in oklab,var(--color-yellow-500) 25%,transparent)}}.border-yellow-500\/30{border-color:#edb2004d}@supports (color:color-mix(in lab,red,red)){.border-yellow-500\/30{border-color:color-mix(in oklab,var(--color-yellow-500) 30%,transparent)}}.border-t-white{border-top-color:var(--color-white)}.\!bg-\[\#0a0a0a\]{background-color:#0a0a0a!important}.\!bg-\[\#444\]{background-color:#444!important}.\!bg-transparent{background-color:#0000!important}.bg-\[\#0a0a0a\]{background-color:#0a0a0a}.bg-\[\#1a1a1a\]{background-color:#1a1a1a}.bg-\[\#1e1e1e\]{background-color:#1e1e1e}.bg-\[\#2a2a2a\]{background-color:#2a2a2a}.bg-\[\#22c55e\]\/10{background-color:#22c55e1a}.bg-\[\#111\]{background-color:#111}.bg-\[\#222\]{background-color:#222}.bg-\[\#555\]{background-color:#555}.bg-\[\#888\]{background-color:#888}.bg-\[\#050505\]{background-color:#050505}.bg-\[\#252525\]{background-color:#252525}.bg-\[rgba\(255\,255\,255\,0\.02\)\]{background-color:#ffffff05}.bg-\[rgba\(255\,255\,255\,0\.3\)\]{background-color:#ffffff4d}.bg-\[rgba\(255\,255\,255\,0\.04\)\]{background-color:#ffffff0a}.bg-\[rgba\(255\,255\,255\,0\.05\)\]{background-color:#ffffff0d}.bg-\[rgba\(255\,255\,255\,0\.08\)\]{background-color:#ffffff14}.bg-\[rgba\(255\,255\,255\,0\.12\)\]{background-color:#ffffff1f}.bg-black{background-color:var(--color-black)}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab,red,red)){.bg-black\/80{background-color:color-mix(in oklab,var(--color-black) 80%,transparent)}}.bg-blue-400{background-color:var(--color-blue-400)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\/10{background-color:color-mix(in oklab,var(--color-blue-500) 10%,transparent)}}.bg-blue-500\/20{background-color:#3080ff33}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\/20{background-color:color-mix(in oklab,var(--color-blue-500) 20%,transparent)}}.bg-blue-500\/\[0\.12\]{background-color:#3080ff1f}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\/\[0\.12\]{background-color:color-mix(in oklab,var(--color-blue-500) 12%,transparent)}}.bg-emerald-400{background-color:var(--color-emerald-400)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-500\/5{background-color:#00bb7f0d}@supports (color:color-mix(in lab,red,red)){.bg-emerald-500\/5{background-color:color-mix(in oklab,var(--color-emerald-500) 5%,transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab,red,red)){.bg-emerald-500\/10{background-color:color-mix(in oklab,var(--color-emerald-500) 10%,transparent)}}.bg-emerald-500\/\[0\.06\]{background-color:#00bb7f0f}@supports (color:color-mix(in lab,red,red)){.bg-emerald-500\/\[0\.06\]{background-color:color-mix(in oklab,var(--color-emerald-500) 6%,transparent)}}.bg-gray-500{background-color:var(--color-gray-500)}.bg-gray-500\/10{background-color:#6a72821a}@supports (color:color-mix(in lab,red,red)){.bg-gray-500\/10{background-color:color-mix(in oklab,var(--color-gray-500) 10%,transparent)}}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-500\/10{background-color:#fe6e001a}@supports (color:color-mix(in lab,red,red)){.bg-orange-500\/10{background-color:color-mix(in oklab,var(--color-orange-500) 10%,transparent)}}.bg-orange-500\/20{background-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.bg-orange-500\/20{background-color:color-mix(in oklab,var(--color-orange-500) 20%,transparent)}}.bg-purple-500{background-color:var(--color-purple-500)}.bg-purple-500\/10{background-color:#ac4bff1a}@supports (color:color-mix(in lab,red,red)){.bg-purple-500\/10{background-color:color-mix(in oklab,var(--color-purple-500) 10%,transparent)}}.bg-purple-500\/\[0\.08\]{background-color:#ac4bff14}@supports (color:color-mix(in lab,red,red)){.bg-purple-500\/\[0\.08\]{background-color:color-mix(in oklab,var(--color-purple-500) 8%,transparent)}}.bg-red-400{background-color:var(--color-red-400)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\/5{background-color:#fb2c360d}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/5{background-color:color-mix(in oklab,var(--color-red-500) 5%,transparent)}}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/10{background-color:color-mix(in oklab,var(--color-red-500) 10%,transparent)}}.bg-red-500\/15{background-color:#fb2c3626}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/15{background-color:color-mix(in oklab,var(--color-red-500) 15%,transparent)}}.bg-red-500\/20{background-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/20{background-color:color-mix(in oklab,var(--color-red-500) 20%,transparent)}}.bg-red-500\/\[0\.12\]{background-color:#fb2c361f}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/\[0\.12\]{background-color:color-mix(in oklab,var(--color-red-500) 12%,transparent)}}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/8{background-color:#ffffff14}@supports (color:color-mix(in lab,red,red)){.bg-white\/8{background-color:color-mix(in oklab,var(--color-white) 8%,transparent)}}.bg-white\/60{background-color:#fff9}@supports (color:color-mix(in lab,red,red)){.bg-white\/60{background-color:color-mix(in oklab,var(--color-white) 60%,transparent)}}.bg-white\/\[0\.03\]{background-color:#ffffff08}@supports (color:color-mix(in lab,red,red)){.bg-white\/\[0\.03\]{background-color:color-mix(in oklab,var(--color-white) 3%,transparent)}}.bg-white\/\[0\.08\]{background-color:#ffffff14}@supports (color:color-mix(in lab,red,red)){.bg-white\/\[0\.08\]{background-color:color-mix(in oklab,var(--color-white) 8%,transparent)}}.bg-white\/\[0\.015\]{background-color:#ffffff04}@supports (color:color-mix(in lab,red,red)){.bg-white\/\[0\.015\]{background-color:color-mix(in oklab,var(--color-white) 1.5%,transparent)}}.bg-yellow-400{background-color:var(--color-yellow-400)}.bg-yellow-500{background-color:var(--color-yellow-500)}.bg-yellow-500\/10{background-color:#edb2001a}@supports (color:color-mix(in lab,red,red)){.bg-yellow-500\/10{background-color:color-mix(in oklab,var(--color-yellow-500) 10%,transparent)}}.bg-yellow-500\/15{background-color:#edb20026}@supports (color:color-mix(in lab,red,red)){.bg-yellow-500\/15{background-color:color-mix(in oklab,var(--color-yellow-500) 15%,transparent)}}.bg-yellow-500\/20{background-color:#edb20033}@supports (color:color-mix(in lab,red,red)){.bg-yellow-500\/20{background-color:color-mix(in oklab,var(--color-yellow-500) 20%,transparent)}}.bg-gradient-to-br{--tw-gradient-position:to bottom right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-emerald-400{--tw-gradient-from:var(--color-emerald-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-cyan-500{--tw-gradient-to:var(--color-cyan-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.p-0{padding:0}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-3\.5{padding:calc(var(--spacing) * 3.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-10{padding:calc(var(--spacing) * 10)}.px-0{padding-inline:0}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-8{padding-block:calc(var(--spacing) * 8)}.pt-1{padding-top:var(--spacing)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-10{padding-top:calc(var(--spacing) * 10)}.pt-\[5px\]{padding-top:5px}.pr-1{padding-right:var(--spacing)}.pr-1\.5{padding-right:calc(var(--spacing) * 1.5)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pb-2\.5{padding-bottom:calc(var(--spacing) * 2.5)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-10{padding-bottom:calc(var(--spacing) * 10)}.pl-1\.5{padding-left:calc(var(--spacing) * 1.5)}.pl-2\.5{padding-left:calc(var(--spacing) * 2.5)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:var(--font-geist-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[15px\]{font-size:15px}.text-\[16px\]{font-size:16px}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-\[22px\]{--tw-leading:22px;line-height:22px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.4em\]{--tw-tracking:.4em;letter-spacing:.4em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[\#22c55e\]{color:#22c55e}.text-\[\#333\]{color:#333}.text-\[\#444\]{color:#444}.text-\[\#555\]{color:#555}.text-\[\#666\]{color:#666}.text-\[\#777\]{color:#777}.text-\[\#888\]{color:#888}.text-\[\#999\]{color:#999}.text-\[\#aaa\]{color:#aaa}.text-\[\#bbb\]{color:#bbb}.text-\[\#ddd\]{color:#ddd}.text-\[\#e5e5e5\]{color:#e5e5e5}.text-\[\#ededed\]{color:#ededed}.text-amber-400{color:var(--color-amber-400)}.text-amber-400\/80{color:#fcbb00cc}@supports (color:color-mix(in lab,red,red)){.text-amber-400\/80{color:color-mix(in oklab,var(--color-amber-400) 80%,transparent)}}.text-black{color:var(--color-black)}.text-blue-400{color:var(--color-blue-400)}.text-blue-400\/80{color:#54a2ffcc}@supports (color:color-mix(in lab,red,red)){.text-blue-400\/80{color:color-mix(in oklab,var(--color-blue-400) 80%,transparent)}}.text-blue-500{color:var(--color-blue-500)}.text-cyan-400{color:var(--color-cyan-400)}.text-cyan-400\/80{color:#00d2efcc}@supports (color:color-mix(in lab,red,red)){.text-cyan-400\/80{color:color-mix(in oklab,var(--color-cyan-400) 80%,transparent)}}.text-emerald-200{color:var(--color-emerald-200)}.text-emerald-400{color:var(--color-emerald-400)}.text-emerald-400\/30{color:#00d2944d}@supports (color:color-mix(in lab,red,red)){.text-emerald-400\/30{color:color-mix(in oklab,var(--color-emerald-400) 30%,transparent)}}.text-emerald-400\/60{color:#00d29499}@supports (color:color-mix(in lab,red,red)){.text-emerald-400\/60{color:color-mix(in oklab,var(--color-emerald-400) 60%,transparent)}}.text-emerald-400\/70{color:#00d294b3}@supports (color:color-mix(in lab,red,red)){.text-emerald-400\/70{color:color-mix(in oklab,var(--color-emerald-400) 70%,transparent)}}.text-emerald-400\/80{color:#00d294cc}@supports (color:color-mix(in lab,red,red)){.text-emerald-400\/80{color:color-mix(in oklab,var(--color-emerald-400) 80%,transparent)}}.text-gray-400{color:var(--color-gray-400)}.text-orange-400{color:var(--color-orange-400)}.text-orange-400\/60{color:#ff8b1a99}@supports (color:color-mix(in lab,red,red)){.text-orange-400\/60{color:color-mix(in oklab,var(--color-orange-400) 60%,transparent)}}.text-orange-400\/80{color:#ff8b1acc}@supports (color:color-mix(in lab,red,red)){.text-orange-400\/80{color:color-mix(in oklab,var(--color-orange-400) 80%,transparent)}}.text-orange-500{color:var(--color-orange-500)}.text-purple-400{color:var(--color-purple-400)}.text-purple-400\/60{color:#c07eff99}@supports (color:color-mix(in lab,red,red)){.text-purple-400\/60{color:color-mix(in oklab,var(--color-purple-400) 60%,transparent)}}.text-purple-400\/70{color:#c07effb3}@supports (color:color-mix(in lab,red,red)){.text-purple-400\/70{color:color-mix(in oklab,var(--color-purple-400) 70%,transparent)}}.text-purple-400\/80{color:#c07effcc}@supports (color:color-mix(in lab,red,red)){.text-purple-400\/80{color:color-mix(in oklab,var(--color-purple-400) 80%,transparent)}}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.text-red-400\/30{color:#ff65684d}@supports (color:color-mix(in lab,red,red)){.text-red-400\/30{color:color-mix(in oklab,var(--color-red-400) 30%,transparent)}}.text-red-400\/50{color:#ff656880}@supports (color:color-mix(in lab,red,red)){.text-red-400\/50{color:color-mix(in oklab,var(--color-red-400) 50%,transparent)}}.text-red-400\/60{color:#ff656899}@supports (color:color-mix(in lab,red,red)){.text-red-400\/60{color:color-mix(in oklab,var(--color-red-400) 60%,transparent)}}.text-red-400\/70{color:#ff6568b3}@supports (color:color-mix(in lab,red,red)){.text-red-400\/70{color:color-mix(in oklab,var(--color-red-400) 70%,transparent)}}.text-red-400\/80{color:#ff6568cc}@supports (color:color-mix(in lab,red,red)){.text-red-400\/80{color:color-mix(in oklab,var(--color-red-400) 80%,transparent)}}.text-red-500{color:var(--color-red-500)}.text-sky-400{color:var(--color-sky-400)}.text-sky-400\/80{color:#00bcfecc}@supports (color:color-mix(in lab,red,red)){.text-sky-400\/80{color:color-mix(in oklab,var(--color-sky-400) 80%,transparent)}}.text-white{color:var(--color-white)}.text-white\/80{color:#fffc}@supports (color:color-mix(in lab,red,red)){.text-white\/80{color:color-mix(in oklab,var(--color-white) 80%,transparent)}}.text-yellow-400{color:var(--color-yellow-400)}.text-yellow-400\/80{color:#fac800cc}@supports (color:color-mix(in lab,red,red)){.text-yellow-400\/80{color:color-mix(in oklab,var(--color-yellow-400) 80%,transparent)}}.text-yellow-500{color:var(--color-yellow-500)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.line-through{text-decoration-line:line-through}.opacity-0{opacity:0}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-90{opacity:.9}.opacity-100{opacity:1}.\!shadow-none{--tw-shadow:0 0 #0000!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.\[font-variant-ligatures\:none\]{font-variant-ligatures:none}@media(hover:hover){.group-hover\:bg-\[rgba\(255\,255\,255\,0\.2\)\]:is(:where(.group):hover *){background-color:#fff3}.group-hover\:text-\[\#aaa\]:is(:where(.group):hover *){color:#aaa}.group-hover\:text-white:is(:where(.group):hover *){color:var(--color-white)}.group-hover\:opacity-90:is(:where(.group):hover *){opacity:.9}.group-hover\/code\:opacity-100:is(:where(.group\/code):hover *){opacity:1}}.placeholder\:text-\[\#444\]::placeholder{color:#444}@media(hover:hover){.hover\:border-\[\#333\]:hover{border-color:#333}.hover\:border-\[\#444\]:hover{border-color:#444}.hover\:border-\[\#555\]:hover{border-color:#555}.hover\:border-emerald-500\/40:hover{border-color:#00bb7f66}@supports (color:color-mix(in lab,red,red)){.hover\:border-emerald-500\/40:hover{border-color:color-mix(in oklab,var(--color-emerald-500) 40%,transparent)}}.hover\:border-white\/\[0\.12\]:hover{border-color:#ffffff1f}@supports (color:color-mix(in lab,red,red)){.hover\:border-white\/\[0\.12\]:hover{border-color:color-mix(in oklab,var(--color-white) 12%,transparent)}}.hover\:border-white\/\[0\.16\]:hover{border-color:#ffffff29}@supports (color:color-mix(in lab,red,red)){.hover\:border-white\/\[0\.16\]:hover{border-color:color-mix(in oklab,var(--color-white) 16%,transparent)}}.hover\:bg-\[\#1a1a1a\]:hover{background-color:#1a1a1a}.hover\:bg-\[\#2a2a2a\]:hover{background-color:#2a2a2a}.hover\:bg-\[rgba\(255\,255\,255\,0\.06\)\]:hover{background-color:#ffffff0f}.hover\:bg-\[rgba\(255\,255\,255\,0\.08\)\]:hover{background-color:#ffffff14}.hover\:bg-\[rgba\(255\,255\,255\,0\.09\)\]:hover{background-color:#ffffff17}.hover\:bg-neutral-200:hover{background-color:var(--color-neutral-200)}.hover\:bg-white\/\[0\.06\]:hover{background-color:#ffffff0f}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/\[0\.06\]:hover{background-color:color-mix(in oklab,var(--color-white) 6%,transparent)}}.hover\:text-\[\#888\]:hover{color:#888}.hover\:text-\[\#aaa\]:hover{color:#aaa}.hover\:text-\[\#ccc\]:hover{color:#ccc}.hover\:text-\[\#ededed\]:hover{color:#ededed}.hover\:text-red-400:hover{color:var(--color-red-400)}.hover\:text-white:hover{color:var(--color-white)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-90:hover{opacity:.9}.hover\:opacity-100:hover{opacity:1}}.focus\:border-\[\#444\]:focus{border-color:#444}.focus\:border-white\/50:focus{border-color:#ffffff80}@supports (color:color-mix(in lab,red,red)){.focus\:border-white\/50:focus{border-color:color-mix(in oklab,var(--color-white) 50%,transparent)}}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-white\/10:focus{--tw-ring-color:#ffffff1a}@supports (color:color-mix(in lab,red,red)){.focus\:ring-white\/10:focus{--tw-ring-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-white\/20:focus-visible{--tw-ring-color:#fff3}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-white\/20:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:opacity-60:disabled{opacity:.6}@media(min-width:40rem){.sm\:rounded-lg{border-radius:var(--radius-lg)}.sm\:p-8{padding:calc(var(--spacing) * 8)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-12{padding-block:calc(var(--spacing) * 12)}}@media(min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(min-width:64rem){.lg\:sticky{position:sticky}.lg\:top-6{top:calc(var(--spacing) * 6)}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:max-h-\[calc\(100vh-3rem\)\]{max-height:calc(100vh - 3rem)}.lg\:grid-cols-\[1fr_340px\]{grid-template-columns:1fr 340px}.lg\:overflow-y-auto{overflow-y:auto}.lg\:border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.lg\:border-\[\#2a2a2a\]{border-color:#2a2a2a}.lg\:pl-6{padding-left:calc(var(--spacing) * 6)}}.\[\&_svg\]\:h-3\.5 svg{height:calc(var(--spacing) * 3.5)}.\[\&_svg\]\:w-3\.5 svg{width:calc(var(--spacing) * 3.5)}}:root{--font-geist-sans:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;--font-geist-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace}html,body{color:#fff;font-family:var(--font-geist-sans);background:#000}.scrollbar-thin{scrollbar-width:thin;scrollbar-color:#ffffff26 transparent}.scrollbar-thin::-webkit-scrollbar{width:6px;height:6px}.scrollbar-thin::-webkit-scrollbar-thumb{background:#ffffff26;border-radius:3px}.scrollbar-thin::-webkit-scrollbar-track{background:0 0}@keyframes page-in{0%{opacity:0;filter:blur(8px);transform:translateY(8px)}to{opacity:1;filter:blur();transform:translateY(0)}}.animate-page-in{animation:.15s ease-out page-in}@keyframes fade-in{0%{opacity:0}to{opacity:1}}.animate-fade-in{animation:.35s ease-out fade-in}@keyframes cardIn{0%{opacity:0;filter:blur(4px);transform:translateY(8px)scale(.97)}to{opacity:1;filter:blur();transform:translateY(0)scale(1)}}.animate-card-in{opacity:0;animation:.3s cubic-bezier(.16,1,.3,1) forwards cardIn}.animate-card-in:first-child{animation-delay:0s}.animate-card-in:nth-child(2){animation-delay:50ms}.animate-card-in:nth-child(3){animation-delay:.1s}.animate-card-in:nth-child(4){animation-delay:.15s}@keyframes shimmer{0%{transform:translate(-100%)}to{transform:translate(400%)}}.animate-shimmer{animation:2s infinite shimmer}@keyframes dialog-overlay-in{0%{opacity:0}to{opacity:1}}@keyframes dialog-overlay-out{0%{opacity:1}to{opacity:0}}@keyframes dialog-panel-in{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}@keyframes dialog-panel-out{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.95)}}.dialog-overlay[data-state=open]{animation:.2s dialog-overlay-in}.dialog-overlay[data-state=closed]{animation:.2s forwards dialog-overlay-out}.dialog-panel[data-state=open]{animation:.2s dialog-panel-in}.dialog-panel[data-state=closed]{animation:.2s forwards dialog-panel-out}.agent-modal[data-state=open]{animation:.14s dialog-overlay-in}.agent-modal[data-state=closed]{animation:.14s forwards dialog-overlay-out}@keyframes tab-in{0%{opacity:0;filter:blur(4px);transform:translateY(6px)}to{opacity:1;filter:blur();transform:translateY(0)}}.animate-tab-in{animation:.2s ease-out tab-in}.prose-markdown{color:#999;word-wrap:break-word;overflow-wrap:break-word;font-size:14px;line-height:1.7}.prose-markdown p{margin-bottom:.75em}.prose-markdown p:last-child{margin-bottom:0}.prose-markdown strong{color:#ccc;font-weight:600}.prose-markdown em{font-style:italic}.prose-markdown code{color:#ccc;font-variant-ligatures:none;background:#0a0a0a;border:1px solid #111;border-radius:4px;padding:.15em .4em;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:.9em}.prose-markdown pre{font-variant-ligatures:none;background:0 0;border:none;border-radius:0;margin:0;padding:0}.prose-markdown pre code{color:inherit;background:0 0;border:none;padding:0;font-size:13px}.prose-markdown ul,.prose-markdown ol{margin-bottom:.75em;padding-left:1.5em}.prose-markdown ul{list-style-type:disc}.prose-markdown ol{list-style-type:decimal}.prose-markdown li{margin-bottom:.25em}.prose-markdown li>ul,.prose-markdown li>ol{margin-top:.25em;margin-bottom:.25em;padding-left:1.5em}.prose-markdown ol+ul{margin-top:-.5em;padding-left:3em}.prose-markdown h1,.prose-markdown h2,.prose-markdown h3,.prose-markdown h4,.prose-markdown h5,.prose-markdown h6{color:#ddd;margin-top:1em;margin-bottom:.5em;font-weight:600}.prose-markdown a{color:inherit;pointer-events:none;text-decoration:none}.prose-markdown blockquote{color:#777;border-left:3px solid #333;margin:.75em 0;padding-left:1em}.prose-markdown hr{border:none;border-top:1px solid #222;margin:1em 0}.prose-markdown>table{border-collapse:collapse;width:100%;margin:.75em 0}.prose-markdown>table th,.prose-markdown>table td{text-align:left;border:1px solid #333;padding:.4em .75em;font-size:13px}.prose-markdown>table th{color:#ccc;background:#1a1a1a;font-weight:600}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}} diff --git a/strix/interface/viewer/static/index.html b/strix/interface/viewer/static/index.html index 1e46c38f..642ebfcf 100644 --- a/strix/interface/viewer/static/index.html +++ b/strix/interface/viewer/static/index.html @@ -6,8 +6,8 @@ Strix Results - - + +
diff --git a/strix/interface/viewer/transcript.py b/strix/interface/viewer/transcript.py index 0011315c..4c3cf045 100644 --- a/strix/interface/viewer/transcript.py +++ b/strix/interface/viewer/transcript.py @@ -7,6 +7,7 @@ import logging from typing import TYPE_CHECKING, Any from strix.core.paths import run_record_path +from strix.interface.tui.live_view import TuiLiveView if TYPE_CHECKING: @@ -41,12 +42,9 @@ def severity_counts(vulns: list[Any]) -> dict[str, int]: def build_run_state(run_dir: Path) -> dict[str, Any]: """Agent graph + full per-agent event/message stream. - Reuses the Textual-free ``TuiLiveView`` projection so the viewer and the TUI + Reuses the shared ``TuiLiveView`` projection so the viewer and the TUI share one parser for ``agents.json`` + ``agents.db`` and never drift. """ - # Imported lazily so importing strix.interface.viewer does not eagerly pull the TUI. - from strix.interface.tui.live_view import TuiLiveView - view = TuiLiveView() view.hydrate_from_run_dir(run_dir) return {"agents": list(view.agents.values()), "events": view.events} diff --git a/strix/runtime/session_manager.py b/strix/runtime/session_manager.py index b4e06389..4b61d735 100644 --- a/strix/runtime/session_manager.py +++ b/strix/runtime/session_manager.py @@ -36,8 +36,14 @@ _PROTECTED_METADATA_NAMES = (".git", ".agents", ".codex") def _host_identity_env() -> dict[str, str]: - if sys.platform != "linux": + # Read the platform through a local so it is not narrowed to whichever OS is + # type-checking: comparing sys.platform directly makes one of these branches + # statically dead, and which one flips between Linux and macOS. + platform_name: str = sys.platform + if platform_name != "linux": return {} + # Bind-mount ownership only needs mapping on Linux, where the container uid + # must match the host's. return {"STRIX_HOST_UID": str(os.getuid()), "STRIX_HOST_GID": str(os.getgid())} diff --git a/strix/telemetry/_common.py b/strix/telemetry/_common.py index ff53ceef..7923a506 100644 --- a/strix/telemetry/_common.py +++ b/strix/telemetry/_common.py @@ -13,6 +13,12 @@ logger = logging.getLogger(__name__) SESSION_ID: str = uuid4().hex[:16] +# (connect, read) seconds. Telemetry is a beacon, never something a user waits +# on, and these calls sit on the shutdown path: an endpoint that is blackholed by +# a firewall stalls in connect, so the cap has to be short enough that quitting +# still feels immediate. +SEND_TIMEOUT: tuple[float, float] = (2.0, 3.0) + _FIRST_RUN_CACHED: bool | None = None diff --git a/strix/telemetry/logging.py b/strix/telemetry/logging.py index 6685eac0..75265e33 100644 --- a/strix/telemetry/logging.py +++ b/strix/telemetry/logging.py @@ -87,6 +87,33 @@ def configure_dependency_logging() -> None: logging.getLogger("asyncio").setLevel(logging.CRITICAL) logging.getLogger("asyncio").propagate = False warnings.filterwarnings("ignore", category=RuntimeWarning, module="asyncio") + _silence_urllib3_finalizer_noise() + + +_unraisable_hook_installed = False + + +def _is_urllib3_closed_file_noise(unraisable: sys.UnraisableHookArgs) -> bool: + return ( + isinstance(unraisable.exc_value, ValueError) + and "I/O operation on closed file" in str(unraisable.exc_value) + and type(unraisable.object).__module__.split(".")[0] == "urllib3" + ) + + +def _silence_urllib3_finalizer_noise() -> None: + global _unraisable_hook_installed # noqa: PLW0603 + if _unraisable_hook_installed: + return + _unraisable_hook_installed = True + previous = sys.unraisablehook + + def hook(unraisable: sys.UnraisableHookArgs) -> None: + if _is_urllib3_closed_file_noise(unraisable): + return + previous(unraisable) + + sys.unraisablehook = hook def setup_scan_logging(run_dir: Path, *, debug: bool | None = None) -> Callable[[], None]: diff --git a/strix/telemetry/posthog.py b/strix/telemetry/posthog.py index 9d6e3907..083e2c95 100644 --- a/strix/telemetry/posthog.py +++ b/strix/telemetry/posthog.py @@ -6,6 +6,7 @@ import requests from strix.config import load_settings from strix.telemetry._common import ( + SEND_TIMEOUT, SESSION_ID, base_props, is_first_run, @@ -37,7 +38,8 @@ def _send(event: str, properties: dict[str, Any]) -> bool: "distinct_id": SESSION_ID, "properties": properties, } - requests.post(f"{_POSTHOG_HOST}/capture/", json=payload, timeout=10) + with requests.post(f"{_POSTHOG_HOST}/capture/", json=payload, timeout=SEND_TIMEOUT): + pass except Exception: # noqa: BLE001 logger.debug("posthog send failed for event %s", event, exc_info=True) return False diff --git a/strix/telemetry/scarf.py b/strix/telemetry/scarf.py index 8da40b72..161e9980 100644 --- a/strix/telemetry/scarf.py +++ b/strix/telemetry/scarf.py @@ -9,6 +9,7 @@ import requests from strix.config import load_settings from strix.telemetry._common import ( + SEND_TIMEOUT, SESSION_ID, base_props, get_version, @@ -43,7 +44,8 @@ def _send(event: str, properties: dict[str, Any]) -> bool: url = f"{_SCARF_ENDPOINT}{path}" if query: url = f"{url}?{query}" - requests.post(url, timeout=10) + with requests.post(url, timeout=SEND_TIMEOUT): + pass except Exception: # noqa: BLE001 logger.debug("scarf send failed for event %s", event, exc_info=True) return False diff --git a/strix/tools/web_search/tool.py b/strix/tools/web_search/tool.py index 04482755..796a950e 100644 --- a/strix/tools/web_search/tool.py +++ b/strix/tools/web_search/tool.py @@ -68,9 +68,9 @@ def _do_search(query: str) -> dict[str, Any]: # noqa: PLR0911 - each error clas } try: - response = requests.post(url, headers=headers, json=payload, timeout=300) - response.raise_for_status() - content = response.json()["choices"][0]["message"]["content"] + with requests.post(url, headers=headers, json=payload, timeout=300) as response: + response.raise_for_status() + content = response.json()["choices"][0]["message"]["content"] except requests.exceptions.Timeout: logger.warning("web_search timed out") return { diff --git a/tests/test_cli_target_list.py b/tests/test_cli_target_list.py index 35982da3..ce5f15f7 100644 --- a/tests/test_cli_target_list.py +++ b/tests/test_cli_target_list.py @@ -3,6 +3,7 @@ from __future__ import annotations import importlib +import json import sys from types import SimpleNamespace from typing import TYPE_CHECKING, Any @@ -83,3 +84,84 @@ def test_parse_arguments_rejects_resume_with_target_list( cli_main.parse_arguments() assert "Cannot combine --resume with --target/--target-list" in capsys.readouterr().err + + +def _write_run_record(runs_dir: Path, run_name: str, record: dict[str, Any]) -> None: + """Write a resumable run: its record plus the agent snapshot resume needs.""" + run_dir = runs_dir / run_name + run_dir.mkdir(parents=True, exist_ok=True) + (run_dir / "run.json").write_text(json.dumps(record), encoding="utf-8") + state_dir = run_dir / ".state" + state_dir.mkdir(exist_ok=True) + (state_dir / "agents.json").write_text("{}", encoding="utf-8") + + +def test_resume_restores_a_target_less_workspace_mount( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A run that only mounted a working directory is resumable.""" + work = tmp_path / "project" + work.mkdir() + monkeypatch.chdir(tmp_path) + _write_run_record( + tmp_path / "strix_runs", + "pentest_abcd", + { + "run_name": "pentest_abcd", + "targets_info": [], + "local_sources": [], + "workspace_mount": str(work), + "instruction": "audit the auth flow", + "scan_mode": "deep", + }, + ) + monkeypatch.setattr(sys, "argv", ["strix", "--resume", "pentest_abcd"]) + + args = cli_main.parse_arguments() + + # Still genuinely target-less, and the workspace is mounted again. + assert args.targets_info == [] + assert args.workspace_mount == str(work) + assert args.local_sources == [ + {"source_path": str(work), "workspace_subdir": "project", "protect_metadata": True} + ] + assert args.instruction == "audit the auth flow" + + +def test_resume_reports_a_missing_workspace_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.chdir(tmp_path) + _write_run_record( + tmp_path / "strix_runs", + "pentest_abcd", + { + "run_name": "pentest_abcd", + "targets_info": [], + "local_sources": [], + "workspace_mount": str(tmp_path / "deleted"), + }, + ) + monkeypatch.setattr(sys, "argv", ["strix", "--resume", "pentest_abcd"]) + + with pytest.raises(SystemExit): + cli_main.parse_arguments() + + assert "is missing" in capsys.readouterr().err + + +def test_resume_still_requires_targets_or_a_workspace( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.chdir(tmp_path) + _write_run_record( + tmp_path / "strix_runs", + "pentest_abcd", + {"run_name": "pentest_abcd", "targets_info": [], "local_sources": []}, + ) + monkeypatch.setattr(sys, "argv", ["strix", "--resume", "pentest_abcd"]) + + with pytest.raises(SystemExit): + cli_main.parse_arguments() + + assert "has no targets_info" in capsys.readouterr().err diff --git a/tests/test_codex_auth.py b/tests/test_codex_auth.py index ba6bb307..98242d8a 100644 --- a/tests/test_codex_auth.py +++ b/tests/test_codex_auth.py @@ -58,6 +58,7 @@ def test_post_form_returns_parsed_body() -> None: resp = mock.MagicMock() resp.status_code = 200 resp.content = b'{"access_token": "tok"}' + resp.__enter__.return_value = resp with mock.patch.object(requests, "post", return_value=resp) as post: data = codex._post_form({"grant_type": "refresh_token"}) diff --git a/tests/test_cost_tracking.py b/tests/test_cost_tracking.py index 065b7cb8..30d4db44 100644 --- a/tests/test_cost_tracking.py +++ b/tests/test_cost_tracking.py @@ -226,8 +226,11 @@ def test_streamed_openrouter_costs_ignores_entries_without_cost() -> None: def test_streamed_openrouter_costs_cleared_on_new_run() -> None: streamed_openrouter_costs.remember("gen-stale", {"cost": 0.7}) - set_global_report_state(ReportState.__new__(ReportState)) - assert streamed_openrouter_costs.take(SimpleNamespace(id="gen-stale")) is None + try: + set_global_report_state(ReportState.__new__(ReportState)) + assert streamed_openrouter_costs.take(SimpleNamespace(id="gen-stale")) is None + finally: + set_global_report_state(None) def test_openrouter_stream_handler_records_cost() -> None: diff --git a/tests/test_go_tui_runtime.py b/tests/test_go_tui_runtime.py new file mode 100644 index 00000000..ee5c0a23 --- /dev/null +++ b/tests/test_go_tui_runtime.py @@ -0,0 +1,907 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import shutil +import socket +import struct +import sys +import threading +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast + +import pytest + +from strix.config.settings import DEFAULT_MAX_TURNS +from strix.interface.tui import runtime as go_tui +from strix.interface.tui import sidecar +from strix.interface.tui.runtime import GoTuiRuntime + + +def args() -> argparse.Namespace: + return argparse.Namespace( + needs_setup=True, + targets_info=[], + instruction=None, + scan_mode="deep", + max_budget_usd=None, + max_turns=DEFAULT_MAX_TURNS, + scope_mode="auto", + diff_base=None, + local_sources=[], + diff_scope={"active": False}, + user_explicit_instruction=None, + run_name="test-run", + ) + + +def test_binary_command_prefers_packaged_sidecar( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Any, +) -> None: + sidecar = tmp_path / "strix-tui" + sidecar.write_text("binary") + monkeypatch.setattr(go_tui, "tui_source_dir", lambda: tmp_path / "tui-src") + monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: sidecar) + monkeypatch.setattr( + shutil, + "which", + lambda _name: pytest.fail("PATH lookup should not run"), + ) + + assert GoTuiRuntime.binary_command() == [str(sidecar)] + + +def test_binary_command_prefers_current_source_over_packaged_sidecar( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Any, +) -> None: + source = tmp_path / "tui-src" + source.mkdir() + (source / "go.mod").write_text("module test\n") + sidecar = tmp_path / "strix-tui" + sidecar.write_text("stale") + monkeypatch.setattr(go_tui, "tui_source_dir", lambda: tmp_path / "tui-src") + monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: sidecar) + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/go" if name == "go" else None) + + assert GoTuiRuntime.binary_command() == ["go", "run", "./cmd/strix-tui"] + + +def test_binary_command_reports_missing_sidecar( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Any, +) -> None: + monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: tmp_path / "missing") + monkeypatch.setattr(shutil, "which", lambda _name: None) + monkeypatch.setattr(go_tui, "tui_source_dir", lambda: tmp_path / "tui-src") + + with pytest.raises(RuntimeError, match="Bubble Tea TUI binary not found"): + GoTuiRuntime.binary_command() + + +def test_binary_command_ignores_unconstrained_path_sidecar( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Any, +) -> None: + monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: tmp_path / "missing") + monkeypatch.setattr(go_tui, "tui_source_dir", lambda: tmp_path / "tui-src") + monkeypatch.setattr(shutil, "which", lambda _name: "/untrusted/path/strix-tui") + + with pytest.raises(RuntimeError, match="Bubble Tea TUI binary not found"): + GoTuiRuntime.binary_command() + + +def test_child_environment_excludes_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "openai-secret") + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "aws-id") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "aws-secret") + monkeypatch.setenv("AWS_SESSION_TOKEN", "aws-token") + monkeypatch.setenv("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/aws-token") + monkeypatch.setenv("VERTEXAI_CREDENTIALS", '{"private_key":"secret"}') + monkeypatch.setenv("STRIX_TUI_TOKEN", "stale-transport-token") + monkeypatch.setenv("TERM", "xterm-256color") + + env = sidecar.child_environment() + + assert env["TERM"] == "xterm-256color" + assert "OPENAI_API_KEY" not in env + assert "AWS_ACCESS_KEY_ID" not in env + assert "AWS_SECRET_ACCESS_KEY" not in env + assert "AWS_SESSION_TOKEN" not in env + assert "AWS_WEB_IDENTITY_TOKEN_FILE" not in env + assert "VERTEXAI_CREDENTIALS" not in env + assert "STRIX_TUI_TOKEN" not in env + + +def test_accept_authenticated_connection() -> None: + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + address = listener.getsockname() + + def connect() -> None: + with socket.create_connection(address) as connection: + connection.sendall(b"one-use-token") + + thread = threading.Thread(target=connect) + thread.start() + connection = sidecar._accept_authenticated_connection(listener, "one-use-token") + connection.close() + listener.close() + thread.join() + + +def test_rejects_invalid_connection_token() -> None: + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + address = listener.getsockname() + + def connect() -> None: + with socket.create_connection(address) as connection: + connection.sendall(b"invalidd-token") + + thread = threading.Thread(target=connect) + thread.start() + with pytest.raises(PermissionError, match="authentication failed"): + sidecar._accept_authenticated_connection(listener, "expected-token") + listener.close() + thread.join() + + +@pytest.mark.asyncio +async def test_windows_transport_launches_without_inherited_fd() -> None: + child = """ +import os +import socket + +host, port = os.environ["STRIX_TUI_ADDR"].rsplit(":", 1) +with socket.create_connection((host, int(port))) as connection: + connection.sendall(os.environ["STRIX_TUI_TOKEN"].encode("ascii")) +""" + env = os.environ.copy() + env.pop("STRIX_TUI_FD", None) + + process, connection = await sidecar._launch_windows_tui_process( + [sys.executable, "-c", child], env, None + ) + connection.close() + + assert await sidecar.wait_process(process) == 0 + + +async def _receive_exactly(connection: socket.socket, size: int) -> bytes: + result = b"" + while len(result) < size: + chunk = await asyncio.get_running_loop().sock_recv(connection, size - len(result)) + if not chunk: + raise EOFError + result += chunk + return result + + +async def _receive_message(connection: socket.socket) -> dict[str, Any]: + size = struct.unpack(">I", await _receive_exactly(connection, 4))[0] + message: dict[str, Any] = json.loads(await _receive_exactly(connection, size)) + return message + + +async def _send_message(connection: socket.socket, message: dict[str, Any]) -> None: + raw = json.dumps(message).encode() + await asyncio.get_running_loop().sock_sendall(connection, struct.pack(">I", len(raw)) + raw) + + +@pytest.mark.asyncio +async def test_runtime_does_not_initialize_or_scan_before_ready( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_args = args() + runtime_args.needs_setup = False + runtime = GoTuiRuntime(runtime_args) + backend, child = socket.socketpair() + child.setblocking(False) # noqa: FBT003 + calls: list[str] = [] + scan_started = asyncio.Event() + + async def launch( + _command: list[str], _env: dict[str, str], _cwd: str | None + ) -> tuple[SimpleNamespace, socket.socket]: + return SimpleNamespace(returncode=None), backend + + async def wait_process(_process: object) -> int: + await scan_started.wait() + return 0 + + def init_state() -> None: + calls.append("state") + + def start_scan() -> None: + calls.append("scan") + scan_started.set() + + async def preflight(_model: str) -> None: + calls.append("preflight") + + monkeypatch.setattr(runtime, "binary_command", lambda: ["test-sidecar"]) + monkeypatch.setattr(go_tui, "launch_tui_process", launch) + monkeypatch.setattr(go_tui, "wait_process", wait_process) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr(go_tui, "persist_current", lambda: None) + monkeypatch.setattr(go_tui, "prepare_run", lambda _args: None) + monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None) + monkeypatch.setattr(runtime, "init_run_state", init_state) + monkeypatch.setattr(runtime, "start_scan", start_scan) + + run_task = asyncio.create_task(runtime.run()) + try: + hello = await _receive_message(child) + assert hello["type"] == "hello" + assert calls == [] + await _send_message( + child, + { + "version": 3, + "type": "ready", + "payload": { + "capabilities": [ + "state-revisions", + "collection-deltas", + "structured-command-errors", + "agents-collection", + ] + }, + }, + ) + await asyncio.wait_for(run_task, timeout=2) + assert calls == ["preflight", "state", "scan"] + finally: + child.close() + if not run_task.done(): + run_task.cancel() + + +@pytest.mark.asyncio +async def test_pre_activation_failure_propagates_to_dispatcher( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FailedRuntime: + async def run(self) -> None: + raise go_tui.GoTuiPreActivationError("protocol mismatch") + + monkeypatch.setattr(go_tui, "GoTuiRuntime", lambda _args: FailedRuntime()) + + with pytest.raises(go_tui.GoTuiPreActivationError, match="protocol mismatch"): + await go_tui.run_go_tui(args()) + + +@pytest.mark.asyncio +async def test_post_activation_failure_is_surfaced( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class ActivatedRuntime: + async def run(self) -> None: + raise RuntimeError("sidecar failed after ready") + + monkeypatch.setattr(go_tui, "GoTuiRuntime", lambda _args: ActivatedRuntime()) + + with pytest.raises(RuntimeError, match="after ready"): + await go_tui.run_go_tui(args()) + + +@pytest.mark.asyncio +async def test_setup_preflights_model_before_starting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_args = args() + runtime_args.instruction = "CLI instruction" + runtime = GoTuiRuntime(runtime_args) + assert runtime.controller.instruction == "CLI instruction" + runtime.controller.targets = ["https://example.com", "/workspace/mounted"] + runtime.controller.scan_mode = "quick" + runtime.controller.instruction = "" + runtime.controller.max_budget_usd = 8.5 + runtime.controller.max_turns = 321 + runtime.controller.scope_mode = "diff" + runtime.controller.diff_base = "origin/main" + calls: list[str] = [] + + async def preflight(model: str) -> None: + assert model == "openrouter/test-model" + calls.append("preflight") + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), + ) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + + def build(candidate: argparse.Namespace, **_: object) -> None: + calls.append("targets") + assert candidate.target == ["https://example.com", "/workspace/mounted"] + candidate.targets_info = [ + { + "type": "web", + "details": {"target_url": "https://example.com"}, + "original": "https://example.com", + }, + { + "type": "local_code", + "details": {"target_path": "/workspace/mounted"}, + "original": "/workspace/mounted", + }, + ] + + def prepare(candidate: argparse.Namespace) -> None: + calls.append("prepare") + assert candidate.max_budget_usd == 8.5 + assert candidate.max_turns == 321 + assert candidate.scope_mode == "diff" + assert candidate.diff_base == "origin/main" + + monkeypatch.setattr(go_tui, "build_targets_info", build) + monkeypatch.setattr(go_tui, "prepare_run", prepare) + monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: calls.append("telemetry")) + monkeypatch.setattr(runtime, "init_run_state", lambda: calls.append("state")) + monkeypatch.setattr(runtime, "start_scan", lambda: calls.append("scan")) + + await runtime.start_from_setup() + + assert calls == ["preflight", "targets", "prepare", "telemetry", "state", "scan"] + assert runtime.args.scan_mode == "quick" + assert runtime.args.instruction == "" + assert runtime.args.max_budget_usd == 8.5 + assert runtime.args.max_turns == 321 + assert runtime.args.scope_mode == "diff" + assert runtime.args.diff_base == "origin/main" + + +@pytest.mark.asyncio +async def test_optimistic_setup_skips_model_preflight( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime = GoTuiRuntime(args()) + runtime.controller.targets = [str(Path.cwd())] + calls: list[str] = [] + + async def preflight(_model: str) -> None: + calls.append("preflight") + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), + ) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr(go_tui, "build_targets_info", lambda _args, **_kw: calls.append("targets")) + monkeypatch.setattr(go_tui, "prepare_run", lambda _args: calls.append("prepare")) + monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: calls.append("telemetry")) + monkeypatch.setattr(runtime, "init_run_state", lambda: calls.append("state")) + monkeypatch.setattr(runtime, "start_scan", lambda: calls.append("scan")) + + await runtime.start_from_setup(verify=False) + + # No preflight: the scan launches straight through and any model error + # surfaces once the agent runs. + assert "preflight" not in calls + assert calls == ["targets", "prepare", "telemetry", "state", "scan"] + + +@pytest.mark.asyncio +async def test_confirmed_target_less_launch_mounts_workspace_without_targets( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The working directory reaches the run as a workspace, never as a target.""" + runtime = GoTuiRuntime(args()) + runtime.controller.workspace_mount = str(Path.home()) + prepared: list[argparse.Namespace] = [] + + async def preflight(_model: str) -> None: + return None + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), + ) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr( + go_tui, + "build_targets_info", + lambda _args, **_kw: pytest.fail("a target-less launch must not build targets"), + ) + monkeypatch.setattr(go_tui, "prepare_run", prepared.append) + monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None) + monkeypatch.setattr(runtime, "init_run_state", lambda: None) + monkeypatch.setattr(runtime, "start_scan", lambda: None) + + await runtime.start_from_setup(verify=False) + + assert prepared[0].workspace_mount == str(Path.home()) + assert prepared[0].targets_info == [] + + +@pytest.mark.asyncio +async def test_setup_preserves_prepared_cli_targets( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_args = args() + runtime_args.target = ["https://example.com"] + runtime_args.target_list = [] + runtime_args.targets_info = [ + { + "type": "web", + "details": {"url": "https://example.com"}, + "original": "https://example.com", + } + ] + runtime = GoTuiRuntime(runtime_args) + calls: list[str] = [] + + async def preflight(_model: str) -> None: + calls.append("preflight") + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), + ) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr( + go_tui, + "build_targets_info", + lambda _args, **_kw: pytest.fail("prepared targets should not be rebuilt"), + ) + monkeypatch.setattr(go_tui, "prepare_run", lambda _args: calls.append("prepare")) + monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: calls.append("telemetry")) + monkeypatch.setattr(runtime, "init_run_state", lambda: calls.append("state")) + monkeypatch.setattr(runtime, "start_scan", lambda: calls.append("scan")) + + await runtime.start_from_setup() + + assert runtime.controller.targets == ["https://example.com"] + assert runtime.args.targets_info[0]["type"] == "web" + assert calls == ["preflight", "prepare", "telemetry", "state", "scan"] + + +@pytest.mark.asyncio +async def test_setup_target_change_preserves_local_targets( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_args = args() + runtime_args.target = [] + runtime_args.target_list = ["targets.txt"] + runtime_args.targets_info = [ + { + "type": "local_code", + "details": {"target_path": "/workspace/source"}, + "original": "/workspace/source", + } + ] + runtime = GoTuiRuntime(runtime_args) + runtime.controller.targets.append("https://example.com") + + async def preflight(_model: str) -> None: + return None + + def build(target_args: argparse.Namespace, **_: object) -> None: + assert target_args.target == ["/workspace/source", "https://example.com"] + target_args.targets_info = [ + { + "type": "web", + "details": {"url": "https://example.com"}, + "original": "https://example.com", + }, + { + "type": "local_code", + "details": {"target_path": "/workspace/source"}, + "original": "/workspace/source", + }, + ] + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), + ) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr(go_tui, "build_targets_info", build) + monkeypatch.setattr(go_tui, "prepare_run", lambda _args: None) + monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None) + monkeypatch.setattr(runtime, "init_run_state", lambda: None) + monkeypatch.setattr(runtime, "start_scan", lambda: None) + + await runtime.start_from_setup() + + assert runtime.args.target_list == [] + assert runtime.args.targets_info[0]["type"] == "web" + assert runtime.args.targets_info[1]["type"] == "local_code" + + +@pytest.mark.asyncio +async def test_setup_same_basename_uses_combined_workspace_names_on_retry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + existing_repo = "https://example.com/first/app.git" + added_repo = "https://example.com/second/app.git" + runtime_args = args() + runtime_args.target = [] + runtime_args.target_list = ["targets.txt"] + runtime_args.targets_info = [ + { + "type": "repository", + "details": { + "target_repo": existing_repo, + "workspace_subdir": "app", + "cloned_repo_path": "/clones/app", + }, + "original": existing_repo, + } + ] + runtime = GoTuiRuntime(runtime_args) + runtime.controller.targets.append(added_repo) + prepare_attempts = 0 + started: list[str] = [] + + async def preflight(_model: str) -> None: + return None + + def build(target_args: argparse.Namespace, **_: object) -> None: + assert target_args.target == [existing_repo, added_repo] + target_args.targets_info = [ + { + "type": "repository", + "details": { + "target_repo": existing_repo, + "workspace_subdir": "app", + }, + "original": existing_repo, + }, + { + "type": "repository", + "details": { + "target_repo": added_repo, + "workspace_subdir": "app-2", + }, + "original": added_repo, + }, + ] + + def prepare(candidate: argparse.Namespace) -> None: + nonlocal prepare_attempts + prepare_attempts += 1 + assert [target["details"]["workspace_subdir"] for target in candidate.targets_info] == [ + "app", + "app-2", + ] + if prepare_attempts == 1: + candidate.targets_info[0]["details"]["target_repo"] = "/mutated" + candidate.targets_info[1]["details"]["workspace_subdir"] = "mutated" + raise ValueError("retry setup") + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), + ) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr(go_tui, "build_targets_info", build) + monkeypatch.setattr(go_tui, "prepare_run", prepare) + monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None) + monkeypatch.setattr(runtime, "init_run_state", lambda: started.append("state")) + monkeypatch.setattr(runtime, "start_scan", lambda: started.append("scan")) + + with pytest.raises(ValueError, match="retry setup"): + await runtime.start_from_setup() + + assert runtime.args.targets_info[0]["details"] == { + "target_repo": existing_repo, + "workspace_subdir": "app", + "cloned_repo_path": "/clones/app", + } + assert started == [] + + await runtime.start_from_setup() + + assert prepare_attempts == 2 + assert runtime.args.target_list == [] + assert [target["details"]["workspace_subdir"] for target in runtime.args.targets_info] == [ + "app", + "app-2", + ] + assert runtime.args.targets_info[0]["details"]["target_repo"] == existing_repo + assert started == ["state", "scan"] + + +@pytest.mark.asyncio +async def test_setup_target_rebuild_restores_all_target_fields_on_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_args = args() + runtime_args.target = None + runtime_args.target_list = ["targets.txt"] + runtime_args.targets_info = [ + { + "type": "local_code", + "details": {"target_path": "/workspace/source"}, + "original": "/workspace/source", + } + ] + original_targets_info = json.loads(json.dumps(runtime_args.targets_info)) + runtime = GoTuiRuntime(runtime_args) + runtime.controller.targets.append("https://example.com") + + async def preflight(_model: str) -> None: + return None + + def fail_rebuild(target_args: argparse.Namespace, **_: object) -> None: + target_args.target = ["mutated"] + target_args.target_list = ["mutated.txt"] + target_args.targets_info = [{"original": "partial"}] + raise ValueError("bad target") + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), + ) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr(go_tui, "build_targets_info", fail_rebuild) + + with pytest.raises(ValueError, match="bad target"): + await runtime.start_from_setup() + + assert runtime.args.target is None + assert runtime.args.target_list == ["targets.txt"] + assert runtime.args.targets_info == original_targets_info + + +@pytest.mark.asyncio +async def test_setup_rebuild_canonicalizes_relative_local_target( + tmp_path: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = tmp_path / "source" + source.mkdir() + monkeypatch.chdir(tmp_path) + runtime_args = args() + runtime_args.target = [] + runtime_args.target_list = [] + runtime = GoTuiRuntime(runtime_args) + runtime.controller.targets = ["source"] + prepared = False + + async def preflight(_model: str) -> None: + return None + + def prepare(candidate: argparse.Namespace) -> None: + nonlocal prepared + prepared = True + assert len(candidate.targets_info) == 1 + assert candidate.targets_info[0]["details"]["target_path"] == str(source.resolve()) + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), + ) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr(go_tui, "prepare_run", prepare) + monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None) + monkeypatch.setattr(runtime, "init_run_state", lambda: None) + monkeypatch.setattr(runtime, "start_scan", lambda: None) + + await runtime.start_from_setup() + + assert prepared is True + assert runtime.args.targets_info[0]["original"] == str(source.resolve()) + + +@pytest.mark.asyncio +@pytest.mark.asyncio +async def test_setup_prepare_system_exit_is_recoverable_and_transactional( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_args = args() + runtime_args.instruction = "CLI instruction" + runtime_args.scan_mode = "deep" + runtime_args.target = ["https://example.com"] + runtime_args.target_list = [] + runtime_args.targets_info = [ + { + "type": "web", + "details": {"url": "https://example.com"}, + "original": "https://example.com", + } + ] + original_args = json.loads(json.dumps(vars(runtime_args))) + runtime = GoTuiRuntime(runtime_args) + runtime.controller.scan_mode = "quick" + runtime.controller.instruction = "" + telemetry_started = False + + async def preflight(_model: str) -> None: + return None + + def fail_prepare(candidate: argparse.Namespace) -> None: + assert candidate is not runtime.args + candidate.run_name = "mutated-run" + candidate.targets_info[0]["details"]["url"] = "https://mutated.example" + raise ValueError("invalid diff scope") + + def telemetry(_candidate: argparse.Namespace) -> None: + nonlocal telemetry_started + telemetry_started = True + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), + ) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr(go_tui, "prepare_run", fail_prepare) + monkeypatch.setattr(go_tui, "telemetry_start", telemetry) + + with pytest.raises(ValueError, match="invalid diff scope"): + await runtime.start_from_setup() + + assert vars(runtime.args) == original_args + assert telemetry_started is False + assert runtime.scan_task is None + + +@pytest.mark.asyncio +async def test_scan_passes_max_turns_and_budget(monkeypatch: pytest.MonkeyPatch) -> None: + runtime_args = args() + runtime_args.max_turns = 37 + runtime_args.max_budget_usd = 4.25 + runtime = GoTuiRuntime(runtime_args) + runtime.scan_config = {"run_name": "test-run"} + captured: dict[str, Any] = {} + + async def run_scan(**kwargs: Any) -> None: + captured.update(kwargs) + coordinator = kwargs["coordinator"] + await coordinator.register("root", "Root", parent_id=None) + await coordinator.set_status("root", "stopped") + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(runtime=SimpleNamespace(image="test-image")), + ) + monkeypatch.setattr(go_tui, "run_strix_scan", run_scan) + + await runtime._run_scan() + + assert captured["max_turns"] == 37 + assert captured["max_budget_usd"] == 4.25 + assert runtime.controller.scan_state == "stopped" + + +@pytest.mark.asyncio +async def test_setup_preflight_failure_does_not_start_scan( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime = GoTuiRuntime(args()) + runtime.controller.targets = ["https://example.com"] + started = False + + async def preflight(_model: str) -> None: + raise ValueError("401 Unauthorized") + + def mark_started(*_args: Any) -> None: + nonlocal started + started = True + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), + ) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr(go_tui, "build_targets_info", mark_started) + monkeypatch.setattr(runtime, "init_run_state", mark_started) + monkeypatch.setattr(runtime, "start_scan", mark_started) + + with pytest.raises(RuntimeError, match="Model connection failed: 401 Unauthorized"): + await runtime.start_from_setup() + + assert started is False + assert runtime.scan_task is None + + +@pytest.mark.asyncio +async def test_agent_state_sync_uses_latest_graph_snapshot_shape() -> None: + runtime = GoTuiRuntime(args()) + await runtime.coordinator.register("root", "Strix", parent_id=None) + await runtime.coordinator.register("child", "Recon", parent_id="root") + await runtime.coordinator.set_status("child", "failed", error="provider rejected request") + + await runtime._sync_agent_state() + + assert runtime.live_view.agents["root"]["name"] == "Strix" + child = runtime.live_view.agents["child"] + assert child["name"] == "Recon" + assert child["parent_id"] == "root" + assert child["status"] == "failed" + assert child["error_message"] == "provider rejected request" + + +@pytest.mark.asyncio +async def test_agent_state_sync_projects_completed_report() -> None: + runtime = GoTuiRuntime(args()) + runtime.report_state = cast("Any", SimpleNamespace(run_record={"status": "completed"})) + await runtime.coordinator.register("root", "Strix", parent_id=None) + await runtime.coordinator.set_status("root", "completed") + + await runtime._sync_agent_state() + + assert runtime.controller.scan_state == "completed" + + +@pytest.mark.asyncio +async def test_agent_state_sync_does_not_mask_root_failure_with_completed_report() -> None: + runtime = GoTuiRuntime(args()) + runtime.report_state = cast("Any", SimpleNamespace(run_record={"status": "completed"})) + await runtime.coordinator.register("root", "Strix", parent_id=None) + await runtime.coordinator.set_status("root", "failed", error="finalization failed") + + await runtime._sync_agent_state() + + assert runtime.controller.scan_state == "failed" + assert runtime.controller.error == "finalization failed" + + +def _direct_launch_args() -> argparse.Namespace: + launch_args = args() + launch_args.needs_setup = False + return launch_args + + +@pytest.mark.asyncio +async def test_prepare_and_start_reports_ordinary_connection_failures( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime = GoTuiRuntime(_direct_launch_args()) + started: list[str] = [] + + async def preflight(_model: str) -> None: + raise TimeoutError("connection timed out") + + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr(runtime, "start_scan", lambda: started.append("scan")) + + await runtime.prepare_and_start() + + assert started == [] + assert runtime.controller.setup_mode is False + assert runtime.controller.scan_state == "failed" + assert "connection timed out" in (runtime.controller.error or "") + + +@pytest.mark.asyncio +async def test_prepare_and_start_runs_the_scan_after_preparation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime = GoTuiRuntime(_direct_launch_args()) + order: list[str] = [] + + async def preflight(_model: str) -> None: + order.append("preflight") + + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr(go_tui, "persist_current", lambda: order.append("persist")) + monkeypatch.setattr(go_tui, "prepare_run", lambda _args: order.append("prepare")) + monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: order.append("telemetry")) + monkeypatch.setattr(runtime, "init_run_state", lambda: order.append("state")) + monkeypatch.setattr(runtime, "start_scan", lambda: order.append("scan")) + + await runtime.prepare_and_start() + + assert order == ["preflight", "persist", "prepare", "telemetry", "state", "scan"] + assert runtime.controller.scan_state == "running" diff --git a/tests/test_inputs.py b/tests/test_inputs.py index 83a431ad..ed233262 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -8,7 +8,12 @@ from typing import Any import litellm import pytest -from strix.core.inputs import build_root_task, child_initial_input, make_model_settings +from strix.core.inputs import ( + build_root_task, + build_scope_context, + child_initial_input, + make_model_settings, +) def _child_kwargs(parent_history: list[Any]) -> dict[str, Any]: @@ -202,6 +207,34 @@ def test_build_root_task_web_application_with_instructions() -> None: assert "Special instructions: Focus on auth." in task +def test_build_root_task_workspace_mount_is_not_a_target() -> None: + """A target-less run gets a working directory, not an assessment scope.""" + config = { + "targets": [], + "user_instructions": "Find IDOR in the checkout flow.", + "workspace_mount": "/Users/me/code/api", + "workspace_subdir": "api", + } + task = build_root_task(config) + + assert "Working Directory:" in task + assert "/workspace/api" in task + assert "No scan target was set" in task + assert "Special instructions: Find IDOR in the checkout flow." in task + # It must not be presented as an asset to test. + for label in ("Local Codebases:", "Repositories:", "URLs:", "IP Addresses:"): + assert label not in task + + +def test_build_scope_context_authorizes_nothing_without_targets() -> None: + """A mounted workspace grants no authorized scope.""" + scope = build_scope_context( + {"targets": [], "workspace_mount": "/Users/me/code/api", "workspace_subdir": "api"} + ) + + assert scope["authorized_targets"] == [] + + def test_build_root_task_diff_scope() -> None: config = { "targets": [], diff --git a/tests/test_local_sources.py b/tests/test_local_sources.py index 5c19b937..9984bef6 100644 --- a/tests/test_local_sources.py +++ b/tests/test_local_sources.py @@ -2,11 +2,13 @@ from __future__ import annotations +import argparse from pathlib import Path from typing import Any import pytest +from strix.interface.scan_setup import attach_workspace_mount from strix.interface.utils import ( check_mountable_dir, collect_local_sources, @@ -14,6 +16,7 @@ from strix.interface.utils import ( infer_target_type, read_target_list_file, ) +from strix.runtime.session_manager import build_bind_mounts def _local_target(target_path: str) -> dict[str, Any]: @@ -66,6 +69,55 @@ def test_check_mountable_dir_rejects_home(tmp_path: Path, monkeypatch: pytest.Mo check_mountable_dir(home) +def test_infer_target_type_guards_sensitive_dirs_by_default( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setattr(Path, "home", classmethod(lambda _cls: home)) + + with pytest.raises(ValueError, match="Refusing to mount"): + infer_target_type(str(home)) + + +def test_workspace_mount_is_mounted_without_becoming_a_target( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A workspace mount reaches the sandbox but carries no target semantics. + + It is the directory the agent works in, so it is exempt from the guard that + refuses home directories for scan targets, and it never enters targets_info. + """ + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda _cls: home)) + args = argparse.Namespace(targets_info=[], local_sources=[], workspace_mount=str(home)) + + attach_workspace_mount(args) + + assert args.targets_info == [] + assert args.local_sources == [ + { + "source_path": str(home), + "workspace_subdir": args.workspace_subdir, + "protect_metadata": True, + } + ] + # It is a real bind mount, so the sandbox exposes it under /workspace. + assert build_bind_mounts(args.local_sources)[0]["target"] == ( + f"/workspace/{args.workspace_subdir}" + ) + + +def test_workspace_mount_absent_leaves_local_sources_alone() -> None: + args = argparse.Namespace(targets_info=[], local_sources=[], workspace_mount=None) + + attach_workspace_mount(args) + + assert args.local_sources == [] + + def test_check_mountable_dir_rejects_system_root() -> None: etc = Path("/etc") if not etc.is_dir(): diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 00000000..6e3a2261 --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def test_wheel_build_requires_go(tmp_path: Path) -> None: + uv = shutil.which("uv") + if uv is None: + pytest.skip("uv is required for the packaging smoke test") + + env = os.environ.copy() + env["PATH"] = str(tmp_path / "path-without-go") + result = subprocess.run( # noqa: S603 + [uv, "build", "--wheel", "--out-dir", str(tmp_path / "dist")], + cwd=PROJECT_ROOT, + env=env, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "Go 1.24 or newer is required" in result.stdout + result.stderr diff --git a/tests/test_proxy_renderer.py b/tests/test_proxy_renderer.py deleted file mode 100644 index 341a1f40..00000000 --- a/tests/test_proxy_renderer.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Tests for the proxy tool TUI renderers.""" - -from __future__ import annotations - -from rich.text import Text - -from strix.interface.tui.renderers.proxy_renderer import ViewRequestRenderer - - -def _plain(static: object) -> str: - content = static.content # type: ignore[attr-defined] - return content.plain if isinstance(content, Text) else str(content) - - -def _render(content: str, *, has_more: bool) -> str: - tool_data = { - "status": "completed", - "result": { - "content": content, - "has_more": has_more, - "page": 1, - "total_lines": len(content.split("\n")), - }, - } - return _plain(ViewRequestRenderer.render(tool_data)) - - -_MARKER = "... more content available" - - -def test_more_content_hint_shown_when_over_fifteen_lines() -> None: - content = "\n".join(f"line{i}" for i in range(30)) - - assert _MARKER in _render(content, has_more=False) - - -def test_no_more_content_hint_within_fifteen_lines() -> None: - content = "\n".join(f"line{i}" for i in range(5)) - - assert _MARKER not in _render(content, has_more=False) - - -def test_more_content_hint_shown_when_has_more_flag_set() -> None: - content = "\n".join(f"line{i}" for i in range(3)) - - assert _MARKER in _render(content, has_more=True) diff --git a/tests/test_tui_backend_controller.py b/tests/test_tui_backend_controller.py new file mode 100644 index 00000000..f6cebe13 --- /dev/null +++ b/tests/test_tui_backend_controller.py @@ -0,0 +1,430 @@ +from __future__ import annotations + +import argparse +import asyncio +import os +from pathlib import Path + +import pytest + +from strix.config import apply_config_override, loader +from strix.config.settings import DEFAULT_MAX_TURNS +from strix.interface.tui.backend.controller import TuiController + + +def args() -> argparse.Namespace: + return argparse.Namespace( + needs_setup=True, + targets_info=[], + instruction=None, + scan_mode="deep", + max_budget_usd=None, + max_turns=DEFAULT_MAX_TURNS, + scope_mode="auto", + diff_base=None, + local_sources=[], + diff_scope={"active": False}, + user_explicit_instruction=None, + run_name=None, + ) + + +@pytest.fixture(autouse=True) +def isolated_config(tmp_path: Path) -> None: + for key in ( + "STRIX_LLM", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "LLM_API_KEY", + "LLM_API_BASE", + "AZURE_API_KEY", + "AZURE_API_BASE", + "AZURE_API_VERSION", + ): + os.environ.pop(key, None) + apply_config_override(tmp_path / "config.json") + + +@pytest.mark.asyncio +async def test_setup_state_is_serializable() -> None: + controller = TuiController(args()) + await controller.handle("setup.add_target", {"target": "https://example.com"}) + await controller.handle("setup.set_instruction", {"instruction": "focus on auth"}) + snapshot = controller.snapshot() + assert snapshot["targets"] == ["https://example.com"] + assert snapshot["instruction"] == "focus on auth" + assert snapshot["scan_state"] == "setup" + assert snapshot["scan_mode"] == "deep" + assert snapshot["max_budget_usd"] is None + assert snapshot["max_turns"] == 500 + assert snapshot["scope_mode"] == "auto" + assert snapshot["diff_base"] is None + + +@pytest.mark.asyncio +async def test_setup_instruction_starts_from_cli_and_can_be_cleared() -> None: + setup_args = args() + setup_args.instruction = " CLI instruction " + controller = TuiController(setup_args) + + assert controller.snapshot()["instruction"] == "CLI instruction" + + result = await controller.handle("setup.set_instruction", {"instruction": ""}) + + assert result == {"instruction": ""} + assert controller.snapshot()["instruction"] == "" + + +@pytest.mark.asyncio +async def test_setup_controls_reject_changes_after_start() -> None: + controller = TuiController(args()) + controller.setup_mode = False + controller.scan_started = True + + with pytest.raises(RuntimeError, match="can no longer be changed"): + await controller.handle("setup.add_target", {"target": "https://example.com"}) + + +@pytest.mark.asyncio +async def test_large_target_list_reports_truncated_snapshot_count() -> None: + controller = TuiController(args()) + + for index in range(20): + await controller.handle("setup.add_target", {"target": f"https://target-{index}.example"}) + added = await controller.handle("setup.add_target", {"target": "https://last.example"}) + snapshot = controller.snapshot() + + assert added == {"target": "https://last.example", "total": 21} + assert snapshot["target_count"] == 21 + # The snapshot only carries a bounded prefix of the list. + assert len(snapshot["targets"]) == 16 + + +def test_state_populates_model_warning_for_non_frontier_model() -> None: + os.environ["STRIX_LLM"] = "openai/gpt-3.5-turbo" + loader._cached = None + + warning = TuiController(args()).snapshot()["model_warning"] + + assert "openai/gpt-3.5-turbo" in warning + assert "not a recommended frontier model" in warning + + +def test_setup_restores_prepared_cli_targets() -> None: + setup_args = args() + setup_args.targets_info = [ + {"type": "web", "details": {}, "original": "https://example.com"}, + {"type": "local_code", "details": {}, "original": "/workspace/source"}, + ] + + controller = TuiController(setup_args) + + assert controller.snapshot()["targets"] == ["https://example.com", "/workspace/source"] + + +@pytest.mark.asyncio +async def test_start_validates_model_before_callback() -> None: + started = False + + async def start(_verify: bool = True) -> None: + nonlocal started + started = True + + controller = TuiController(args(), on_start=start) + await controller.handle("setup.add_target", {"target": "https://example.com"}) + with pytest.raises(ValueError, match="No model configured"): + await controller.handle("setup.start", {}) + assert started is False + + +@pytest.mark.asyncio +async def test_start_launches_with_a_configured_model() -> None: + started = False + + async def start(_verify: bool = True) -> None: + nonlocal started + started = True + + os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4" + loader._cached = None + controller = TuiController(args(), on_start=start) + await controller.handle("setup.add_target", {"target": "https://example.com"}) + + result = await controller.handle("setup.start", {}) + + assert result == {"started": True} + assert started is True + + +@pytest.mark.asyncio +async def test_start_without_target_requires_mount_consent() -> None: + started = False + + async def start(_verify: bool = True) -> None: + nonlocal started + started = True + + os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4" + os.environ["ANTHROPIC_API_KEY"] = "test-key" + loader._cached = None + controller = TuiController(args(), on_start=start) + + # Mounting the working directory is never silent. + with pytest.raises(ValueError, match="No target set"): + await controller.handle("setup.start", {"verify": False}) + assert started is False + assert controller.targets == [] + assert controller.workspace_mount is None + + +@pytest.mark.asyncio +async def test_target_less_start_enters_live_view_and_waits_for_the_mount() -> None: + """Nothing is prepared until the live-view confirmation is answered.""" + started = False + + async def start(_verify: bool = True) -> None: + nonlocal started + started = True + + os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4" + os.environ["ANTHROPIC_API_KEY"] = "test-key" + loader._cached = None + controller = TuiController(args(), on_start=start) + + result = await controller.handle("setup.start", {"verify": False, "mount_working_dir": True}) + + assert result == {"started": True} + # The live view is up so the prompt can be shown there, but the scan has not + # been prepared and nothing is mounted yet. + assert started is False + assert controller.setup_mode is False + assert controller.scan_state == "preparing" + assert controller.pending_workspace_mount == str(Path.cwd()) + assert controller.workspace_mount is None + assert controller.snapshot()["pending_mount"] == str(Path.cwd()) + + +@pytest.mark.asyncio +async def test_confirming_the_mount_starts_the_scan_without_a_target() -> None: + started = False + seen_verify: bool | None = None + + async def start(verify: bool = True) -> None: + nonlocal started, seen_verify + started = True + seen_verify = verify + + os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4" + os.environ["ANTHROPIC_API_KEY"] = "test-key" + loader._cached = None + controller = TuiController(args(), on_start=start) + await controller.handle("setup.start", {"verify": False, "mount_working_dir": True}) + + result = await controller.handle("setup.confirm_mount", {"approved": True}) + + assert result == {"approved": True} + assert started is True + # Launched optimistically, and mounted as a workspace: the scan genuinely + # has no target, so the instruction is the only source of truth. + assert seen_verify is False + assert controller.workspace_mount == str(Path.cwd()) + assert controller.targets == [] + assert controller.scan_state == "running" + assert controller.snapshot()["pending_mount"] == "" + + +@pytest.mark.asyncio +async def test_declining_the_mount_returns_to_the_start_screen() -> None: + started = False + + async def start(_verify: bool = True) -> None: + nonlocal started + started = True + + os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4" + os.environ["ANTHROPIC_API_KEY"] = "test-key" + loader._cached = None + controller = TuiController(args(), on_start=start) + await controller.handle("setup.start", {"verify": False, "mount_working_dir": True}) + + result = await controller.handle("setup.confirm_mount", {"approved": False}) + + assert result == {"approved": False} + # Nothing was prepared, so the session goes back to the start screen and can + # be launched again. + assert started is False + assert controller.workspace_mount is None + assert controller.pending_workspace_mount is None + assert controller.setup_mode is True + assert controller.scan_started is False + assert controller.scan_state == "setup" + + +@pytest.mark.asyncio +async def test_confirm_mount_requires_a_pending_request() -> None: + controller = TuiController(args()) + + with pytest.raises(RuntimeError, match="No mount confirmation is pending"): + await controller.handle("setup.confirm_mount", {"approved": True}) + + +def test_snapshot_exposes_working_directory() -> None: + controller = TuiController(args()) + + assert controller.snapshot()["working_dir"] == str(Path.cwd()) + assert controller.snapshot()["pending_mount"] == "" + + +@pytest.mark.asyncio +async def test_start_forwards_verify_flag_by_default() -> None: + seen_verify: bool | None = None + + async def start(verify: bool = True) -> None: + nonlocal seen_verify + seen_verify = verify + + os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4" + os.environ["ANTHROPIC_API_KEY"] = "test-key" + loader._cached = None + controller = TuiController(args(), on_start=start) + await controller.handle("setup.add_target", {"target": "https://example.com"}) + + # A named target keeps the upfront model check. + await controller.handle("setup.start", {}) + + assert seen_verify is True + + +@pytest.mark.asyncio +async def test_start_rejects_concurrent_and_repeated_submissions() -> None: + entered = asyncio.Event() + release = asyncio.Event() + + async def start(_verify: bool = True) -> None: + entered.set() + await release.wait() + + os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4" + os.environ["ANTHROPIC_API_KEY"] = "test-key" + loader._cached = None + controller = TuiController(args(), on_start=start) + await controller.handle("setup.add_target", {"target": "https://example.com"}) + + first_start = asyncio.create_task(controller.handle("setup.start", {})) + await entered.wait() + with pytest.raises(RuntimeError, match="already starting or running"): + await controller.handle("setup.start", {}) + release.set() + await first_start + with pytest.raises(RuntimeError, match="already starting or running"): + await controller.handle("setup.start", {}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", ["completed", "failed", "crashed", "stopped"]) +async def test_stop_rejects_terminal_agents(status: str) -> None: + class Coordinator: + def __init__(self) -> None: + self.calls: list[str] = [] + + async def cancel_descendants_graceful(self, agent_id: str) -> bool: + self.calls.append(agent_id) + return True + + coordinator = Coordinator() + controller = TuiController(args(), coordinator=coordinator) + controller.set_runtime(scan_loop=asyncio.get_running_loop()) + controller.live_view.upsert_agent("agent-1", name="Agent", status=status) + + with pytest.raises(RuntimeError, match=f"cannot be stopped while {status}"): + await controller.handle("agent.stop", {"agent_id": "agent-1"}) + + assert coordinator.calls == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", ["running", "waiting", "budget_paused"]) +async def test_stop_allows_active_agents(status: str) -> None: + class Coordinator: + def __init__(self) -> None: + self.calls: list[str] = [] + + async def cancel_descendants_graceful(self, agent_id: str) -> bool: + self.calls.append(agent_id) + return True + + coordinator = Coordinator() + controller = TuiController(args(), coordinator=coordinator) + controller.set_runtime(scan_loop=asyncio.get_running_loop()) + controller.live_view.upsert_agent("agent-1", name="Agent", status=status) + + result = await controller.handle("agent.stop", {"agent_id": "agent-1"}) + + assert result == {"stopped": True} + assert coordinator.calls == ["agent-1"] + + +@pytest.mark.asyncio +async def test_stop_handles_coordinator_rejection_after_stale_active_projection() -> None: + class Coordinator: + async def cancel_descendants_graceful(self, _agent_id: str) -> bool: + return False + + controller = TuiController(args(), coordinator=Coordinator()) + controller.set_runtime(scan_loop=asyncio.get_running_loop()) + controller.live_view.upsert_agent("agent-1", name="Agent", status="running") + + with pytest.raises(RuntimeError, match="no longer active"): + await controller.handle("agent.stop", {"agent_id": "agent-1"}) + + +@pytest.mark.asyncio +async def test_unknown_command_is_rejected() -> None: + controller = TuiController(args()) + with pytest.raises(ValueError, match="Unknown command"): + await controller.handle("nope", {}) + + +def test_messages_are_sanitized_and_agents_are_collection_only() -> None: + controller = TuiController(args()) + controller.add_message("replace\x1b]52;c;Y2xpcA==\x07 key\x85") + for index in range(40): + controller.live_view.upsert_agent(f"agent-{index}", name=f"Agent {index}") + + snapshot = controller.snapshot() + + assert "agents" not in snapshot + assert [message["text"] for message in snapshot["messages"]] == ["replace key"] + assert len(controller.collection("agents")) == 40 + + +@pytest.mark.asyncio +async def test_existing_viewer_is_reopened_and_closed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + opened: list[str] = [] + + class ViewerServer: + shutdown_called = False + close_called = False + + def shutdown(self) -> None: + self.shutdown_called = True + + def server_close(self) -> None: + self.close_called = True + + controller = TuiController(args()) + controller.viewer_status = "running" + controller.viewer_url = "http://127.0.0.1:1234/?token=test" + server = ViewerServer() + controller._viewer_httpd = server + monkeypatch.setattr("strix.interface.tui.backend.controller.webbrowser.open", opened.append) + + result = await controller.handle("viewer.open", {}) + controller.close_viewer() + + assert result == {"status": "running", "url": controller.viewer_url} + assert opened == [controller.viewer_url] + assert server.shutdown_called is True + assert server.close_called is True diff --git a/tests/test_tui_backend_server.py b/tests/test_tui_backend_server.py new file mode 100644 index 00000000..d3e08088 --- /dev/null +++ b/tests/test_tui_backend_server.py @@ -0,0 +1,560 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import socket +import struct +from types import SimpleNamespace +from typing import Any, cast + +import pytest +from agents.tool import ToolOutputImage + +from strix.config.settings import DEFAULT_MAX_TURNS +from strix.interface.tui.backend.controller import TuiController +from strix.interface.tui.backend.projection import terminal_projection +from strix.interface.tui.backend.protocol import ( + MAX_COMMAND_BYTES, + PROTOCOL_CAPABILITIES, + PROTOCOL_VERSION, + ProtocolHandshakeError, + envelope, +) +from strix.interface.tui.backend.server import TuiBackendServer +from strix.interface.tui.live_view import TuiLiveView + + +def args() -> argparse.Namespace: + return argparse.Namespace( + needs_setup=True, + targets_info=[], + instruction=None, + scan_mode="deep", + max_budget_usd=None, + max_turns=DEFAULT_MAX_TURNS, + scope_mode="auto", + diff_base=None, + local_sources=[], + diff_scope={"active": False}, + user_explicit_instruction=None, + run_name=None, + ) + + +async def send_message(connection: socket.socket, message: dict[str, object]) -> None: + raw = json.dumps(message).encode() + await asyncio.get_running_loop().sock_sendall(connection, struct.pack(">I", len(raw)) + raw) + + +async def receive_exactly(connection: socket.socket, size: int) -> bytes: + chunks: list[bytes] = [] + while size: + chunk = await asyncio.get_running_loop().sock_recv(connection, size) + if not chunk: + raise EOFError + chunks.append(chunk) + size -= len(chunk) + return b"".join(chunks) + + +async def receive_frame(connection: socket.socket) -> tuple[int, dict[str, Any]]: + size = struct.unpack(">I", await receive_exactly(connection, 4))[0] + value = json.loads(await receive_exactly(connection, size)) + assert isinstance(value, dict) + return size, value + + +async def receive_message(connection: socket.socket) -> dict[str, Any]: + return (await receive_frame(connection))[1] + + +async def start_server( + server: TuiBackendServer, backend: socket.socket, child: socket.socket +) -> dict[str, Any]: + start_task = asyncio.create_task(server.start(backend)) + hello = await receive_message(child) + await send_message( + child, + { + "version": PROTOCOL_VERSION, + "type": "ready", + "payload": {"capabilities": list(PROTOCOL_CAPABILITIES)}, + }, + ) + await asyncio.wait_for(start_task, timeout=1) + return hello + + +async def receive_until( + connection: socket.socket, + message_type: str, + *, + request_id: str | None = None, +) -> dict[str, Any]: + for _ in range(100): + message = await asyncio.wait_for(receive_message(connection), timeout=2) + if message.get("type") != message_type: + continue + if request_id is not None and message.get("request_id") != request_id: + continue + return message + raise AssertionError(f"did not receive {message_type}") + + +async def receive_initial_state(connection: socket.socket) -> None: + state_received = False + complete: set[str] = set() + while not state_received or complete != {"agents", "events", "vulnerabilities"}: + message = await asyncio.wait_for(receive_message(connection), timeout=2) + if message["type"] == "state": + state_received = True + elif message["type"] == "collection_bootstrap": + payload = message["payload"] + if payload["done"]: + complete.add(payload["collection"]) + + +@pytest.mark.asyncio +async def test_server_requires_ready_before_state_or_commands() -> None: + backend, child = socket.socketpair() + child.setblocking(False) # noqa: FBT003 + server = TuiBackendServer(TuiController(args())) + start_task = asyncio.create_task(server.start(backend)) + try: + hello = await receive_message(child) + assert hello == { + "version": 3, + "type": "hello", + "payload": {"capabilities": list(PROTOCOL_CAPABILITIES)}, + } + with pytest.raises(TimeoutError): + await asyncio.wait_for(receive_message(child), timeout=0.1) + assert not start_task.done() + + await send_message( + child, + { + "version": 3, + "type": "ready", + "payload": {"capabilities": list(PROTOCOL_CAPABILITIES)}, + }, + ) + await asyncio.wait_for(start_task, timeout=1) + assert server.activated is True + assert (await receive_until(child, "state"))["payload"]["revision"] == 1 + finally: + child.close() + start_task.cancel() + await server.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("version", "capabilities"), + [ + (2, list(PROTOCOL_CAPABILITIES)), + (3, ["state-revisions"]), + ], +) +async def test_server_rejects_handshake_mismatch(version: int, capabilities: list[str]) -> None: + backend, child = socket.socketpair() + child.setblocking(False) # noqa: FBT003 + server = TuiBackendServer(TuiController(args())) + start_task = asyncio.create_task(server.start(backend)) + try: + await receive_message(child) + await send_message( + child, + {"version": version, "type": "ready", "payload": {"capabilities": capabilities}}, + ) + with pytest.raises(ProtocolHandshakeError, match="mismatch"): + await asyncio.wait_for(start_task, timeout=1) + assert server.activated is False + finally: + child.close() + await server.close() + + +@pytest.mark.asyncio +async def test_server_command_round_trip_over_inherited_socket() -> None: + backend, child = socket.socketpair() + child.setblocking(False) # noqa: FBT003 + server = TuiBackendServer(TuiController(args())) + await start_server(server, backend, child) + try: + await send_message( + child, + { + "version": 3, + "type": "setup.add_target", + "request_id": "test-1", + "payload": {"target": "example.com"}, + }, + ) + result = await receive_until(child, "command_result", request_id="test-1") + assert result["payload"]["ok"] is True + assert result["payload"]["command"] == "setup.add_target" + state = await receive_until(child, "state") + assert state["payload"]["revision"] >= 1 + assert state["payload"]["state"]["targets"] == ["example.com"] + finally: + child.close() + await server.close() + + +def test_unicode_heavy_setup_state_stays_within_control_frame_limit() -> None: + controller = TuiController(args()) + controller.instruction = "🔒" * 10_000 + controller.targets = [f"https://例え.{index}/" + "界" * 500 for index in range(20)] + controller.error = "失" * 10_000 + controller.messages = [ + {"id": str(index), "text": "警" * 10_000, "level": "warning"} for index in range(10) + ] + controller.report_state = cast( + "Any", + SimpleNamespace( + caido_url="https://例え.example/" + "道" * 10_000, + get_total_llm_usage=lambda: {f"model-{index}": "費" * 10_000 for index in range(20)}, + ), + ) + server = TuiBackendServer(controller) + + snapshot = controller.snapshot() + encoded = server._encode(envelope("state", {"revision": 1, "state": snapshot})) + + assert len(encoded) <= MAX_COMMAND_BYTES + assert "🔒".encode() in encoded + assert snapshot["projection_truncated"] is True + + +@pytest.mark.asyncio +async def test_persistence_error_does_not_kill_command_reader( + monkeypatch: pytest.MonkeyPatch, +) -> None: + backend, child = socket.socketpair() + child.setblocking(False) # noqa: FBT003 + controller = TuiController(args()) + calls = 0 + + async def handle(command: str, payload: dict[str, Any]) -> dict[str, Any]: + nonlocal calls + calls += 1 + if calls == 1: + raise OSError("disk is read-only") + return {"command": command, "payload": payload} + + monkeypatch.setattr(controller, "handle", handle) + server = TuiBackendServer(controller) + await start_server(server, backend, child) + try: + for request_id in ("persist-1", "persist-2"): + await send_message( + child, + { + "version": 3, + "type": "setup.select_model", + "request_id": request_id, + "payload": {"provider": "openai", "model": "openai/gpt-5"}, + }, + ) + result = await receive_until(child, "command_result", request_id=request_id) + if request_id == "persist-1": + assert result["payload"]["error"] == { + "code": "persistence_error", + "message": "disk is read-only", + "retryable": True, + } + else: + assert result["payload"]["ok"] is True + assert server._reader_task is not None and not server._reader_task.done() + finally: + child.close() + await server.close() + + +@pytest.mark.asyncio +async def test_invalid_version_error_is_correlated_and_next_command_succeeds() -> None: + backend, child = socket.socketpair() + child.setblocking(False) # noqa: FBT003 + server = TuiBackendServer(TuiController(args())) + await start_server(server, backend, child) + try: + await send_message( + child, + { + "version": 2, + "type": "setup.add_target", + "request_id": "bad-version", + "payload": {"target": "ignored.example"}, + }, + ) + rejected = await receive_until(child, "command_result", request_id="bad-version") + assert rejected["payload"]["error"]["code"] == "invalid_request" + + await send_message( + child, + { + "version": 3, + "type": "setup.add_target", + "request_id": "after-error", + "payload": {"target": "example.com"}, + }, + ) + accepted = await receive_until(child, "command_result", request_id="after-error") + assert accepted["payload"]["ok"] is True + finally: + child.close() + await server.close() + + +@pytest.mark.asyncio +async def test_collection_bootstrap_is_chunked_deltas_are_incremental_and_idle_is_silent() -> None: + backend, child = socket.socketpair() + child.setblocking(False) # noqa: FBT003 + controller = TuiController(args()) + report_state = SimpleNamespace( + vulnerability_reports=[], + caido_url=None, + get_total_llm_usage=dict, + ) + controller.report_state = cast("Any", report_state) + content = "x" * (64 * 1024) + for index in range(80): + controller.live_view.record_user_message(f"agent-{index}", content) + server = TuiBackendServer(controller) + await start_server(server, backend, child) + try: + event_frames = 0 + event_count = 0 + complete: set[str] = set() + state_received = False + while not (complete == {"agents", "events", "vulnerabilities"} and state_received): + size, message = await asyncio.wait_for(receive_frame(child), timeout=5) + if message["type"] == "state": + state_received = True + if message["type"] != "collection_bootstrap": + continue + payload = message["payload"] + if payload["collection"] == "events": + event_frames += 1 + event_count += len(payload["items"]) + assert size <= 4 * 1024 * 1024 + if payload["done"]: + complete.add(payload["collection"]) + assert event_frames >= 2 + assert event_count == 80 + + server.notify_changed() + with pytest.raises(TimeoutError): + await asyncio.wait_for(receive_message(child), timeout=0.2) + + controller.live_view.record_user_message("agent-new", "delta") + controller.notify_changed() + delta = await receive_until(child, "collection_delta") + assert delta["payload"]["collection"] == "events" + assert delta["payload"]["base_revision"] == 1 + assert len(delta["payload"]["operations"]) == 1 + + report_state.vulnerability_reports.append( + {"id": "vuln-0001", "title": "Incremental finding", "severity": "high"} + ) + controller.notify_changed() + finding_delta = await receive_until(child, "collection_delta") + assert finding_delta["payload"]["collection"] == "vulnerabilities" + assert len(finding_delta["payload"]["operations"]) == 1 + finally: + child.close() + await server.close() + + +@pytest.mark.asyncio +async def test_agents_collection_has_no_state_cap_and_sends_delete_and_resync() -> None: + backend, child = socket.socketpair() + child.setblocking(False) # noqa: FBT003 + controller = TuiController(args()) + for index in range(40): + controller.live_view.upsert_agent( + f"agent-{index}", + name=f"Agent {index}", + status="running", + ) + server = TuiBackendServer(controller) + await start_server(server, backend, child) + try: + agents: list[dict[str, Any]] = [] + complete: set[str] = set() + state: dict[str, Any] | None = None + while state is None or complete != {"agents", "events", "vulnerabilities"}: + message = await asyncio.wait_for(receive_message(child), timeout=2) + if message["type"] == "state": + state = message["payload"]["state"] + elif message["type"] == "collection_bootstrap": + payload = message["payload"] + if payload["collection"] == "agents": + agents.extend(payload["items"]) + if payload["done"]: + complete.add(payload["collection"]) + + assert "agents" not in state + assert len(agents) == 40 + + controller.live_view.agents.pop("agent-7") + controller.notify_changed() + delta = await receive_until(child, "collection_delta") + assert delta["payload"]["collection"] == "agents" + assert delta["payload"]["operations"] == [{"op": "delete", "id": "agent-7"}] + + await send_message( + child, + { + "version": 3, + "type": "collection.resync", + "request_id": "resync-agents", + "payload": {"collection": "agents"}, + }, + ) + result = await receive_until(child, "command_result", request_id="resync-agents") + assert result["payload"]["ok"] is True + bootstrap = await receive_until(child, "collection_bootstrap") + assert bootstrap["payload"]["collection"] == "agents" + assert bootstrap["payload"]["revision"] == 3 + assert len(bootstrap["payload"]["items"]) == 39 + finally: + child.close() + await server.close() + + +@pytest.mark.asyncio +async def test_bootstrap_larger_than_64_mib_has_no_total_message_ceiling( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = TuiBackendServer(TuiController(args())) + shared_projection = "x" * (1024 * 1024) + items = [{"id": f"event-{index}", "content": shared_projection} for index in range(65)] + frames: list[dict[str, Any]] = [] + encoded_sizes: list[int] = [] + + async def capture(message: dict[str, Any]) -> None: + encoded_sizes.append(len(server._encode(message))) + frames.append(message) + + monkeypatch.setattr(server, "_send", capture) + + await server._send_collection_frames( + "collection_bootstrap", + {"collection": "events", "revision": 1}, + "items", + items, + ) + + assert sum(len(item["content"]) for item in items) > 64 * 1024 * 1024 + assert len(frames) > 16 + assert max(encoded_sizes) <= 4 * 1024 * 1024 + assert frames[0]["payload"]["cursor"] == 0 + assert frames[-1]["payload"]["next_cursor"] == len(items) + assert frames[-1]["payload"]["done"] is True + + +@pytest.mark.asyncio +async def test_oversized_terminal_projection_is_truncated_without_mutating_history() -> None: + controller = TuiController(args()) + durable = "x" * (2 * 1024 * 1024) + controller.live_view.record_user_message("agent", durable) + + projected = controller.collection("events") + + assert len(projected[0]["data"]["content"]) < len(durable) + assert controller.live_view.events[0]["data"]["content"] == durable + + +def test_terminal_projection_strips_ansi_osc_and_c1_controls() -> None: + controller = TuiController(args()) + hostile = "safe\x1b[31mred\x1b[0m\x1b]52;c;Y2xpcGJvYXJk\x07\x85tail" + controller.live_view.record_user_message("agent", hostile) + + projected = controller.collection_snapshot("events")[1][0]["data"]["content"] + + assert projected == "saferedtail" + assert "\x1b" not in projected + + hostile_mapping = {"header\x1b]52;c;Y2xpcA==\x07": "value"} + assert list(terminal_projection(hostile_mapping)) == ["header"] + assert list(TuiBackendServer._sanitize_wire_value(hostile_mapping)) == ["header"] + + +def test_terminal_event_history_is_bounded_without_changing_durable_sessions() -> None: + controller = TuiController(args()) + for index in range(10_050): + controller.live_view.record_user_message("agent", f"message-{index}") + + _cursor, projected = controller.collection_snapshot("events") + + assert len(controller.live_view.events) == 10_000 + assert len(projected) == 5_000 + assert projected[0]["data"]["content"] == "message-5050" + assert projected[-1]["data"]["content"] == "message-10049" + + +@pytest.mark.asyncio +async def test_oversized_command_frame_is_rejected_before_payload_read() -> None: + backend, child = socket.socketpair() + child.setblocking(False) # noqa: FBT003 + server = TuiBackendServer(TuiController(args())) + await start_server(server, backend, child) + try: + await asyncio.get_running_loop().sock_sendall( + child, struct.pack(">I", MAX_COMMAND_BYTES + 1) + ) + assert server._reader_task is not None + await asyncio.wait_for(server._reader_task, timeout=1) + assert server._socket is None + finally: + child.close() + await server.close() + + +@pytest.mark.asyncio +async def test_server_stops_when_peer_closes() -> None: + backend, child = socket.socketpair() + child.setblocking(False) # noqa: FBT003 + server = TuiBackendServer(TuiController(args())) + await start_server(server, backend, child) + child.close() + try: + assert server._reader_task is not None + await asyncio.wait_for(server._reader_task, timeout=1) + finally: + await server.close() + + +def test_image_data_uri_survives_terminal_projection() -> None: + uri = "data:image/png;base64," + "A" * 100_000 + assert terminal_projection(uri) == uri + assert terminal_projection({"type": "image", "image_url": uri})["image_url"] == uri + + oversized = "data:image/png;base64," + "A" * (3 * 1024 * 1024) + assert terminal_projection(oversized) == "[image omitted from terminal projection]" + + +def test_view_image_tool_output_is_normalized_to_image_dict() -> None: + uri = "data:image/png;base64," + "B" * 4000 + view = TuiLiveView() + view._record_tool_output_data( + "agent", + { + "call_id": "c1", + "tool_name": "view_image", + "output": ToolOutputImage(type="image", image_url=uri), + }, + ) + view._record_tool_output_data( + "agent", + { + "call_id": "c2", + "tool_name": "view_image", + "output": [{"type": "input_image", "image_url": uri}], + }, + ) + for event in view.events: + assert event["data"]["result"] == {"type": "image", "image_url": uri} diff --git a/tests/test_tui_protocol_conformance.py b/tests/test_tui_protocol_conformance.py new file mode 100644 index 00000000..3b5500ef --- /dev/null +++ b/tests/test_tui_protocol_conformance.py @@ -0,0 +1,49 @@ +"""Guard against the Python and Go protocol constants drifting apart. + +The wire protocol is declared twice — ``strix/interface/tui/backend/protocol.py`` +for the backend and ``strix/interface/tui/internal/protocol/protocol.go`` for the +sidecar. This test parses the Go source shipped in the tree and checks the two +declarations agree, so a version or capability change in one language cannot +land silently without the other. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from strix.interface.tui.backend.protocol import PROTOCOL_CAPABILITIES, PROTOCOL_VERSION + + +GO_PROTOCOL_SOURCE = ( + Path(__file__).resolve().parents[1] + / "strix" + / "interface" + / "tui" + / "internal" + / "protocol" + / "protocol.go" +) + + +def test_go_protocol_source_is_present() -> None: + assert GO_PROTOCOL_SOURCE.is_file() + + +def test_protocol_version_matches_go() -> None: + source = GO_PROTOCOL_SOURCE.read_text(encoding="utf-8") + match = re.search(r"^const Version = (\d+)$", source, flags=re.MULTILINE) + assert match is not None, "const Version not found in protocol.go" + assert int(match.group(1)) == PROTOCOL_VERSION + + +def test_protocol_capabilities_match_go() -> None: + source = GO_PROTOCOL_SOURCE.read_text(encoding="utf-8") + match = re.search( + r"^var Capabilities = \[\]string\{\n(?P(?:\t\"[^\"]+\",\n)+)\}", + source, + flags=re.MULTILINE, + ) + assert match is not None, "var Capabilities not found in protocol.go" + go_capabilities = re.findall(r"\"([^\"]+)\"", match.group("body")) + assert tuple(go_capabilities) == PROTOCOL_CAPABILITIES diff --git a/tests/test_tui_resume_history.py b/tests/test_tui_resume_history.py new file mode 100644 index 00000000..f1edd8e8 --- /dev/null +++ b/tests/test_tui_resume_history.py @@ -0,0 +1,281 @@ +"""Resumed history must attribute only typed messages to the user. + +Guidance the system feeds an agent is injected as a user turn, so replayed +history cannot tell it apart from a typed message by role alone. A live run only +shows what the user actually typed; resuming has to match that. +""" + +from __future__ import annotations + +import json +import sqlite3 +from typing import TYPE_CHECKING, Any + +import pytest + +from strix.core.paths import runtime_state_dir +from strix.interface.tui.backend.live_view import TuiLiveView as GoTuiLiveView +from strix.interface.tui.live_view import TuiLiveView, _is_internal_agent_turn + + +if TYPE_CHECKING: + from pathlib import Path + + +def _write_run(run_dir: Path, items: list[dict[str, Any]], agent_id: str = "root") -> None: + """Persist an agent snapshot plus a session history for hydration to read.""" + state_dir = runtime_state_dir(run_dir) + state_dir.mkdir(parents=True, exist_ok=True) + (state_dir / "agents.json").write_text( + json.dumps({"statuses": {agent_id: "running"}, "names": {agent_id: "recon"}}), + encoding="utf-8", + ) + connection = sqlite3.connect(state_dir / "agents.db") + try: + connection.execute( + "create table agent_messages (id integer primary key, session_id text, " + "message_data text, created_at text)" + ) + for index, item in enumerate(items, start=1): + connection.execute( + "insert into agent_messages (id, session_id, message_data, created_at) " + "values (?, ?, ?, ?)", + (index, agent_id, json.dumps(item), f"2026-01-01T00:00:{index:02d}+00:00"), + ) + connection.commit() + finally: + connection.close() + + +def _user_messages(view: TuiLiveView) -> list[str]: + return [ + str(event["data"]["content"]) + for event in view.events + if event.get("type") == "chat" and event["data"].get("role") == "user" + ] + + +def test_resume_hides_system_guidance_injected_as_user_turns(tmp_path: Path) -> None: + run_dir = tmp_path / "run" + _write_run( + run_dir, + [ + # The task the agent was launched with, not a typed message. + {"role": "user", "content": "\n\nURLs: - https://example.com"}, + {"role": "assistant", "content": "starting"}, + { + "role": "user", + "content": "[Message from system (system) | type=auto_resume | priority=normal]\n" + "Waiting timeout reached.", + }, + {"role": "user", "content": "[NOTICE] Turn budget: 350/500 used (70%)."}, + # A stall notice reaches the parent through the coordinator, so it + # arrives wrapped rather than as a bare "[Agent stalled]". + { + "role": "user", + "content": "[Message from recon (a1) | type=stalled | priority=high]\n" + "[Agent stalled] recon (a1) kept ending turns", + }, + { + "role": "user", + "content": "Your previous message ended a turn without a tool call. " + "Plain text never ends execution.", + }, + {"role": "assistant", "content": "continuing"}, + ], + ) + view = TuiLiveView() + + view.hydrate_from_run_dir(run_dir) + + assert _user_messages(view) == [] + # The agent's own side of the conversation is untouched. + assert [ + str(event["data"]["content"]) + for event in view.events + if event.get("type") == "chat" and event["data"].get("role") == "assistant" + ] == ["starting", "continuing"] + + +def test_resume_keeps_messages_the_user_actually_typed(tmp_path: Path) -> None: + run_dir = tmp_path / "run" + _write_run( + run_dir, + [ + {"role": "user", "content": "\n\nURLs: - https://example.com"}, + {"role": "assistant", "content": "starting"}, + {"role": "user", "content": "check the coupon endpoint next"}, + {"role": "assistant", "content": "on it"}, + {"role": "user", "content": "[NOTICE] Turn budget: 350/500 used (70%)."}, + {"role": "user", "content": "stop testing the admin panel"}, + ], + ) + view = TuiLiveView() + + view.hydrate_from_run_dir(run_dir) + + assert _user_messages(view) == [ + "check the coupon endpoint next", + "stop testing the admin panel", + ] + + +def test_resume_treats_each_agents_first_user_turn_as_its_task(tmp_path: Path) -> None: + """Subagents get their task the same way, so it is skipped per agent.""" + run_dir = tmp_path / "run" + state_dir = runtime_state_dir(run_dir) + state_dir.mkdir(parents=True, exist_ok=True) + (state_dir / "agents.json").write_text( + json.dumps( + { + "statuses": {"root": "running", "child": "running"}, + "names": {"root": "root", "child": "recon"}, + "parent_of": {"child": "root"}, + } + ), + encoding="utf-8", + ) + connection = sqlite3.connect(state_dir / "agents.db") + try: + connection.execute( + "create table agent_messages (id integer primary key, session_id text, " + "message_data text, created_at text)" + ) + rows = [ + ("root", {"role": "user", "content": "\n\nURLs: - https://example.com"}), + ("child", {"role": "user", "content": "Audit the login flow."}), + ("child", {"role": "user", "content": "also try the password reset"}), + ] + for index, (session_id, item) in enumerate(rows, start=1): + connection.execute( + "insert into agent_messages (id, session_id, message_data, created_at) " + "values (?, ?, ?, ?)", + (index, session_id, json.dumps(item), f"2026-01-01T00:00:{index:02d}+00:00"), + ) + connection.commit() + finally: + connection.close() + view = TuiLiveView() + + view.hydrate_from_run_dir(run_dir) + + # Both tasks are skipped; only the follow-up typed at the child remains. + assert _user_messages(view) == ["also try the password reset"] + + +def test_internal_turn_classifier_matches_every_injected_form() -> None: + for content in ( + # Coordinator deliveries, which wrap the stall, terminal and budget notices. + "[Message from recon (a1) | type=information | priority=normal]\nfound it", + "[Message from recon (a1) | type=stalled | priority=high]\n[Agent stalled] recon (a1)", + "[Message from system (system) | type=budget_extended | priority=normal]\n" + "[Budget] extended", + # Budget warnings, the only notices injected without a wrapper. + "[NOTICE] Turn budget: 350/500 used (70%).", + "[URGENT] Scan cost budget: $9.50/$10.00 spent (95%).", + "[CRITICAL] Turn budget: 480/500 used (96%).", + "== Inherited context from parent (background only) ==", + "Your previous message ended a turn without a tool call.", + "Your previous response ended the autonomous Strix run without a lifecycle tool call.", + ): + assert _is_internal_agent_turn(content), content + + +def test_internal_turn_classifier_keeps_bracketed_user_text() -> None: + """A leading bracket is not enough: typed text often starts with one.""" + for content in ( + '[{"id": 1, "role": "admin"}, {"id": 2}]', + "[link](https://example.com) check this endpoint", + "[URGENT] stop testing the admin panel", + "[2026-01-01 12:00:03] ERROR auth failed - look into this", + "[note] creds are admin:hunter2", + "[Agent] can you check this?", + "[]", + "check the coupon endpoint next", + "Use creds admin:hunter2 for the login form", + "stop", + ): + assert not _is_internal_agent_turn(content), content + + +@pytest.mark.parametrize("view_class", [TuiLiveView, GoTuiLiveView]) +def test_user_instruction_opens_the_transcript_when_the_root_agent_appears( + view_class: type[TuiLiveView], +) -> None: + """A live scan has no root agent yet, so the message waits for it. + + Exercised against the projection the Go TUI actually uses as well as the + base one: that subclass overrides upsert_agent without calling back, so a + hook placed there would silently never run. + """ + view = view_class() + + view.set_user_instruction("find IDOR in the checkout flow") + assert _user_messages(view) == [] + + view.upsert_agent("ab12", name="Strix", parent_id=None, status="running") + assert view.flush_user_instruction() is True + assert _user_messages(view) == ["find IDOR in the checkout flow"] + + # Repeated agent syncs and subagents must not repeat it. + view.upsert_agent("cd34", name="recon", parent_id="ab12", status="running") + view.upsert_agent("ab12", status="running") + assert view.flush_user_instruction() is False + assert _user_messages(view) == ["find IDOR in the checkout flow"] + + +def test_blank_user_instruction_adds_nothing() -> None: + view = TuiLiveView() + + view.set_user_instruction(" ") + view.set_user_instruction(None) + view.upsert_agent("ab12", name="Strix", parent_id=None, status="running") + + assert _user_messages(view) == [] + + +def test_replayed_run_opens_with_the_users_instruction(tmp_path: Path) -> None: + """It comes from the run record and sorts ahead of replayed history.""" + run_dir = tmp_path / "run" + _write_run( + run_dir, + [ + {"role": "user", "content": "\n\nURLs: - https://example.com"}, + {"role": "assistant", "content": "starting"}, + {"role": "user", "content": "also check coupons"}, + ], + ) + (run_dir / "run.json").write_text( + json.dumps( + { + "start_time": "2026-01-01T00:00:00+00:00", + # instruction carries the diff-scope preamble; only the user's own + # text belongs in the transcript. + "instruction": "[diff-scope preamble]\n\naudit the auth flow", + "user_instruction": "audit the auth flow", + } + ), + encoding="utf-8", + ) + view = TuiLiveView() + + view.hydrate_from_run_dir(run_dir) + + assert _user_messages(view) == ["audit the auth flow", "also check coupons"] + first = view.events[0] + assert first["data"]["content"] == "audit the auth flow" + # Stamped with the run's start, so ordering by timestamp keeps it first. + assert first["timestamp"] == "2026-01-01T00:00:00+00:00" + + +def test_replayed_run_without_an_instruction_is_unchanged(tmp_path: Path) -> None: + run_dir = tmp_path / "run" + _write_run(run_dir, [{"role": "assistant", "content": "starting"}]) + (run_dir / "run.json").write_text( + json.dumps({"start_time": "2026-01-01T00:00:00+00:00"}), encoding="utf-8" + ) + view = TuiLiveView() + + view.hydrate_from_run_dir(run_dir) + + assert _user_messages(view) == [] diff --git a/tests/test_unraisable_filter.py b/tests/test_unraisable_filter.py new file mode 100644 index 00000000..83d267ba --- /dev/null +++ b/tests/test_unraisable_filter.py @@ -0,0 +1,53 @@ +import sys + +import pytest +import urllib3.response + +from strix.telemetry import logging as tlog +from strix.telemetry.logging import _is_urllib3_closed_file_noise + + +class _Args: + def __init__(self, exc_value: BaseException | None, obj: object) -> None: + self.exc_type = type(exc_value) if exc_value is not None else None + self.exc_value = exc_value + self.exc_traceback = None + self.err_msg = None + self.object = obj + + +def _urllib3_response() -> urllib3.response.HTTPResponse: + return urllib3.response.HTTPResponse(body=b"") + + +def test_filters_urllib3_closed_file_noise() -> None: + args = _Args(ValueError("I/O operation on closed file."), _urllib3_response()) + assert _is_urllib3_closed_file_noise(args) # type: ignore[arg-type] + + +def test_passes_through_other_unraisables() -> None: + assert not _is_urllib3_closed_file_noise( + _Args(ValueError("I/O operation on closed file."), object()) # type: ignore[arg-type] + ) + assert not _is_urllib3_closed_file_noise( + _Args(RuntimeError("boom"), _urllib3_response()) # type: ignore[arg-type] + ) + assert not _is_urllib3_closed_file_noise( + _Args(ValueError("something else"), _urllib3_response()) # type: ignore[arg-type] + ) + + +def test_installed_hook_filters_and_delegates(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[object] = [] + monkeypatch.setattr(sys, "unraisablehook", calls.append) + monkeypatch.setattr(tlog, "_unraisable_hook_installed", False) + tlog._silence_urllib3_finalizer_noise() + hook = sys.unraisablehook + assert hook is not calls.append + + hook(_Args(ValueError("I/O operation on closed file."), _urllib3_response())) # type: ignore[arg-type] + assert calls == [] + + other = _Args(RuntimeError("boom"), object()) + hook(other) # type: ignore[arg-type] + assert calls == [other] diff --git a/uv.lock b/uv.lock index 8faff233..8b933b4a 100644 --- a/uv.lock +++ b/uv.lock @@ -1077,18 +1077,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] -[[package]] -name = "linkify-it-py" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "uc-micro-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, -] - [[package]] name = "litellm" version = "1.90.1" @@ -1136,14 +1124,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] -[package.optional-dependencies] -linkify = [ - { name = "linkify-it-py" }, -] -plugins = [ - { name = "mdit-py-plugins" }, -] - [[package]] name = "markupsafe" version = "3.0.3" @@ -1232,18 +1212,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, ] -[[package]] -name = "mdit-py-plugins" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -2427,7 +2395,6 @@ dependencies = [ { name = "reportlab" }, { name = "requests" }, { name = "rich" }, - { name = "textual" }, ] [package.optional-dependencies] @@ -2467,7 +2434,6 @@ requires-dist = [ { name = "reportlab", specifier = ">=4.0" }, { name = "requests", specifier = ">=2.32.0" }, { name = "rich" }, - { name = "textual", specifier = ">=6.0.0" }, ] provides-extras = ["vertex", "bedrock"] @@ -2483,22 +2449,6 @@ dev = [ { name = "ruff", specifier = ">=0.11.13" }, ] -[[package]] -name = "textual" -version = "6.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py", extra = ["linkify", "plugins"] }, - { name = "platformdirs" }, - { name = "pygments" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a2/30/38b615f7d4b16f6fdd73e4dcd8913e2d880bbb655e68a076e3d91181a7ee/textual-6.2.1.tar.gz", hash = "sha256:4699d8dfae43503b9c417bd2a6fb0da1c89e323fe91c4baa012f9298acaa83e1", size = 1570645, upload-time = "2025-10-01T16:11:24.467Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/93/02c7adec57a594af28388d85da9972703a4af94ae1399542555cd9581952/textual-6.2.1-py3-none-any.whl", hash = "sha256:3c7190633cd4d8bfe6049ae66808b98da91ded2edb85cef54e82bf77b03d2a54", size = 710702, upload-time = "2025-10-01T16:11:22.161Z" }, -] - [[package]] name = "tiktoken" version = "0.13.0" @@ -2633,15 +2583,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] -[[package]] -name = "uc-micro-py" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, -] - [[package]] name = "urllib3" version = "2.7.0" From 23f1d76d4c6c0fc7f7dd5a8d682964dcc52ef6f1 Mon Sep 17 00:00:00 2001 From: Tech Guy <84954628+lukiod@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:06:59 +0530 Subject: [PATCH 28/57] Create credential files with owner-only permissions (#945) Co-authored-by: Ahmed Allam --- strix/config/codex.py | 11 ++---- strix/config/loader.py | 6 ++-- strix/interface/viewer/auth.py | 10 ++---- strix/utils/secret_files.py | 31 ++++++++++++++++ tests/test_secret_files.py | 65 ++++++++++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 20 deletions(-) create mode 100644 strix/utils/secret_files.py create mode 100644 tests/test_secret_files.py diff --git a/strix/config/codex.py b/strix/config/codex.py index 94bc8470..cf34f003 100644 --- a/strix/config/codex.py +++ b/strix/config/codex.py @@ -24,6 +24,8 @@ from typing import TYPE_CHECKING, Any import requests +from strix.utils.secret_files import write_secret_text + if TYPE_CHECKING: from collections.abc import Iterator @@ -67,14 +69,7 @@ def _read_store() -> dict[str, Any]: def _write_store(data: dict[str, Any]) -> None: - AUTH_PATH.parent.mkdir(parents=True, exist_ok=True) - tmp = AUTH_PATH.with_suffix(".json.tmp") - tmp.write_text(json.dumps(data, indent=2), encoding="utf-8") - with contextlib.suppress(OSError): - tmp.chmod(0o600) - tmp.replace(AUTH_PATH) - with contextlib.suppress(OSError): - AUTH_PATH.chmod(0o600) + write_secret_text(AUTH_PATH, json.dumps(data, indent=2)) def read_record() -> dict[str, Any] | None: diff --git a/strix/config/loader.py b/strix/config/loader.py index 5b940760..fbcde898 100644 --- a/strix/config/loader.py +++ b/strix/config/loader.py @@ -2,7 +2,6 @@ from __future__ import annotations -import contextlib import json import logging import os @@ -12,6 +11,7 @@ from typing import TYPE_CHECKING, Any from pydantic import AliasChoices, BaseModel from strix.config.settings import Settings +from strix.utils.secret_files import write_secret_text if TYPE_CHECKING: @@ -71,9 +71,7 @@ def persist_current() -> None: env_block[alias.upper()] = value break - target.write_text(json.dumps({"env": env_block}, indent=2), encoding="utf-8") - with contextlib.suppress(OSError): - target.chmod(0o600) + write_secret_text(target, json.dumps({"env": env_block}, indent=2)) def _aliases_for(finfo: FieldInfo) -> list[str]: diff --git a/strix/interface/viewer/auth.py b/strix/interface/viewer/auth.py index 45710811..67a0db9d 100644 --- a/strix/interface/viewer/auth.py +++ b/strix/interface/viewer/auth.py @@ -22,6 +22,7 @@ from typing import Any import requests from strix.config.loader import load_settings +from strix.utils.secret_files import write_secret_text logger = logging.getLogger(__name__) @@ -115,15 +116,8 @@ def is_verified() -> bool: def write_auth(email: str, token: str, verified_at: str) -> None: """Atomically persist the auth record with 0600 permissions.""" - AUTH_PATH.parent.mkdir(parents=True, exist_ok=True) payload = json.dumps({"email": email, "token": token, "verified_at": verified_at}) - tmp = AUTH_PATH.with_suffix(".json.tmp") - tmp.write_text(payload, encoding="utf-8") - with contextlib.suppress(OSError): - tmp.chmod(0o600) - tmp.replace(AUTH_PATH) - with contextlib.suppress(OSError): - AUTH_PATH.chmod(0o600) + write_secret_text(AUTH_PATH, payload) def forget() -> None: diff --git a/strix/utils/secret_files.py b/strix/utils/secret_files.py new file mode 100644 index 00000000..b2170bf9 --- /dev/null +++ b/strix/utils/secret_files.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import contextlib +import os +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from pathlib import Path + + +SECRET_FILE_MODE = 0o600 + + +def write_secret_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + tmp = path.with_suffix(path.suffix + ".tmp") + with contextlib.suppress(FileNotFoundError): + tmp.unlink() + + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, SECRET_FILE_MODE) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(text) + except BaseException: + with contextlib.suppress(OSError): + tmp.unlink() + raise + + tmp.replace(path) diff --git a/tests/test_secret_files.py b/tests/test_secret_files.py new file mode 100644 index 00000000..f93c3541 --- /dev/null +++ b/tests/test_secret_files.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import json +import os +import stat +import sys +from typing import TYPE_CHECKING + +import pytest + +from strix.utils.secret_files import SECRET_FILE_MODE, write_secret_text + + +if TYPE_CHECKING: + from pathlib import Path + + +posix_only = pytest.mark.skipif( + sys.platform == "win32", reason="POSIX permission bits are not modelled on Windows" +) + + +def test_content_round_trips(tmp_path: Path) -> None: + target = tmp_path / "nested" / "auth.json" + payload = json.dumps({"token": "s3cret", "refresh": "r3fresh"}) + write_secret_text(target, payload) + assert json.loads(target.read_text(encoding="utf-8"))["token"] == "s3cret" # noqa: S105 + + +@posix_only +def test_file_is_owner_only(tmp_path: Path) -> None: + target = tmp_path / "auth.json" + write_secret_text(target, "{}") + assert stat.S_IMODE(target.stat().st_mode) == SECRET_FILE_MODE + + +@posix_only +def test_a_permissive_umask_cannot_widen_the_file(tmp_path: Path) -> None: + previous = os.umask(0) + try: + target = tmp_path / "auth.json" + write_secret_text(target, "{}") + assert stat.S_IMODE(target.stat().st_mode) == SECRET_FILE_MODE + finally: + os.umask(previous) + + +@posix_only +def test_a_stale_temporary_does_not_leak_its_mode(tmp_path: Path) -> None: + target = tmp_path / "auth.json" + stale = target.with_suffix(target.suffix + ".tmp") + stale.write_text("leftover", encoding="utf-8") + stale.chmod(0o666) + + write_secret_text(target, "{}") + assert stat.S_IMODE(target.stat().st_mode) == SECRET_FILE_MODE + + +def test_overwriting_an_existing_record_keeps_it_restricted(tmp_path: Path) -> None: + target = tmp_path / "auth.json" + write_secret_text(target, json.dumps({"v": 1})) + write_secret_text(target, json.dumps({"v": 2})) + assert json.loads(target.read_text(encoding="utf-8"))["v"] == 2 + if sys.platform != "win32": + assert stat.S_IMODE(target.stat().st_mode) == SECRET_FILE_MODE From 6f70b6f319f37e30ec5e185bced9179b011fe542 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Tue, 4 Aug 2026 03:03:01 +0000 Subject: [PATCH 29/57] fix(tui): drop the shift+enter newline hint from the setup footer --- strix/interface/tui/internal/app/setup.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/strix/interface/tui/internal/app/setup.go b/strix/interface/tui/internal/app/setup.go index 8fb7362e..ec3b1361 100644 --- a/strix/interface/tui/internal/app/setup.go +++ b/strix/interface/tui/internal/app/setup.go @@ -496,8 +496,7 @@ func (m Model) setupHintsView(width int) string { key := lipgloss.NewStyle().Foreground(white).Render label := render.Dim().Render hint := func(k, text string) string { return key(k) + label(" "+text) } - left := hint("enter", "launch scan") + label(" ") + hint("shift+enter", "newline") + - label(" ") + hint("ctrl+c", "quit") + left := hint("enter", "launch scan") + label(" ") + hint("ctrl+c", "quit") if lipgloss.Width(left) > inner { left = hint("enter", "launch scan") } From 4a455b1e620ea8e6178f8ff2c9af3edb2613f5b9 Mon Sep 17 00:00:00 2001 From: Ahmed Allam <49919286+0xallam@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:12:42 +0000 Subject: [PATCH 30/57] fix(core): recover from hallucinated tool names instead of ending the scan A tool call for a name Strix does not register raised ModelBehaviorError from the SDK turn resolver, which nothing retries: the root agent's raise tore down the whole scan and a sub-agent died before its status was set. Opt into the SDK's tool_not_found_behavior="return_error_to_model" so the unknown call comes back as a tool result and the agent self-corrects. The setting landed in openai-agents 0.19.0, which requires openai>=2.45, so both pins move. --- .pre-commit-config.yaml | 2 +- pyproject.toml | 5 +- strix/core/runner.py | 3 + tests/test_runner_rate_limit.py | 3 +- tests/test_runner_root_prompt.py | 24 ++++- tests/test_unknown_tool_recovery.py | 155 ++++++++++++++++++++++++++++ uv.lock | 29 ++---- 7 files changed, 194 insertions(+), 27 deletions(-) create mode 100644 tests/test_unknown_tool_recovery.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f0a96b39..7ceb3cc4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: fastapi, pytest, hatchling, - "openai-agents[litellm]==0.14.6", + "openai-agents[litellm]>=0.19.0,<0.20", ] args: [--install-types, --non-interactive] diff --git a/pyproject.toml b/pyproject.toml index 0655cf68..6a2ac4b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,8 +33,8 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "openai-agents[litellm]==0.14.6", - "openai>=2.26.0,<2.45", + "openai-agents[litellm]>=0.19.0,<0.20", + "openai>=2.45.0,<3", "litellm", "pydantic>=2.11.3", "pydantic-settings>=2.13.0", @@ -233,6 +233,7 @@ ignore = [ "strix/interface/auth_cli.py" = ["N802"] "tests/test_codex_streaming.py" = ["N802"] "tests/test_disable_streaming.py" = ["N802"] +"tests/test_unknown_tool_recovery.py" = ["N802"] "tests/test_report_pdf.py" = ["S105", "S106"] # Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a # circular dependency with strix.telemetry / strix.interface.viewer.report_pdf. diff --git a/strix/core/runner.py b/strix/core/runner.py index 79e9fff2..8d75939f 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -268,6 +268,9 @@ async def run_strix_scan( model_settings=model_settings, sandbox=SandboxRunConfig(client=bundle["client"], session=bundle["session"]), trace_include_sensitive_data=False, + # A hallucinated tool name is a recoverable model mistake, not a scan-ending + # error: hand it back as a tool result so the agent can correct itself. + tool_not_found_behavior="return_error_to_model", ) hooks = ReportUsageHooks( model=resolved_model, diff --git a/tests/test_runner_rate_limit.py b/tests/test_runner_rate_limit.py index 061ad3c5..3110ae2c 100644 --- a/tests/test_runner_rate_limit.py +++ b/tests/test_runner_rate_limit.py @@ -8,6 +8,7 @@ from typing import Any import httpx import pytest +from agents import ModelSettings from openai import RateLimitError import strix.tools.notes.tools as notes_tools @@ -64,7 +65,7 @@ async def test_persistent_rate_limit_stops_gracefully( monkeypatch.setattr(runner, "build_root_task", lambda _scan_config: "task") monkeypatch.setattr(runner, "build_scope_context", lambda _scan_config: "") - monkeypatch.setattr(runner, "make_model_settings", lambda *_args, **_kwargs: object()) + monkeypatch.setattr(runner, "make_model_settings", lambda *_args, **_kwargs: ModelSettings()) monkeypatch.setattr(runner, "build_strix_agent", lambda **_kwargs: object()) monkeypatch.setattr(runner, "make_child_factory", lambda **_kwargs: lambda **_k: object()) monkeypatch.setattr(runner, "open_agent_session", lambda _root_id, _db: object()) diff --git a/tests/test_runner_root_prompt.py b/tests/test_runner_root_prompt.py index cd4d4ac8..2c346203 100644 --- a/tests/test_runner_root_prompt.py +++ b/tests/test_runner_root_prompt.py @@ -11,6 +11,7 @@ from typing import Any import httpx import pytest +from agents import ModelSettings from openai import RateLimitError import strix.tools.notes.tools as notes_tools @@ -74,7 +75,7 @@ def _patch_engine_scaffold( monkeypatch.setattr(runner, "build_root_task", lambda _scan_config: "task") monkeypatch.setattr(runner, "build_scope_context", lambda _scan_config: scope_context) - monkeypatch.setattr(runner, "make_model_settings", lambda *_args, **_kwargs: object()) + monkeypatch.setattr(runner, "make_model_settings", lambda *_args, **_kwargs: ModelSettings()) captured: dict[str, Any] = {} @@ -87,7 +88,8 @@ def _patch_engine_scaffold( monkeypatch.setattr(runner, "make_child_factory", lambda **_kwargs: lambda **_k: object()) monkeypatch.setattr(runner, "open_agent_session", lambda _root_id, _db: object()) - async def _raise_rate_limit(*_args: Any, **_kwargs: Any) -> None: + async def _raise_rate_limit(*_args: Any, **kwargs: Any) -> None: + captured["run_config"] = kwargs.get("run_config") raise _make_rate_limit_error() monkeypatch.setattr(runner, "run_agent_loop", _raise_rate_limit) @@ -176,3 +178,21 @@ async def test_root_prompt_options_default_to_none( kwargs = captured["kwargs"] assert kwargs["instructions_override"] is None assert kwargs["system_prompt_context"] == {"scope": "built-in"} + + +@pytest.mark.asyncio +async def test_unknown_tool_calls_are_returned_to_the_model( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Any, +) -> None: + """A hallucinated tool name must not end the scan.""" + captured = _patch_engine_scaffold(monkeypatch, tmp_path, {}) + + await runner.run_strix_scan( + scan_config={"targets": [], "scan_mode": "deep"}, + scan_id="scan-unknown-tool", + image="img", + coordinator=AgentCoordinator(), + ) + + assert captured["run_config"].tool_not_found_behavior == "return_error_to_model" diff --git a/tests/test_unknown_tool_recovery.py b/tests/test_unknown_tool_recovery.py new file mode 100644 index 00000000..bc6e747e --- /dev/null +++ b/tests/test_unknown_tool_recovery.py @@ -0,0 +1,155 @@ +"""Tests for surviving a hallucinated tool name. + +Models regularly invent tool names that Strix does not register (``read_file`` +is a common one, borrowed from other agent frameworks). The SDK's default is to +raise ``ModelBehaviorError``, which ends the whole run: nothing in Strix retries +it, so one bad token discards a scan. The runner therefore opts into +``tool_not_found_behavior="return_error_to_model"`` so the unknown call comes +back as a tool result and the agent corrects itself on the next turn. +""" + +from __future__ import annotations + +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import TYPE_CHECKING, Any + +import pytest +from agents import Agent, Runner, function_tool +from agents.exceptions import ModelBehaviorError +from agents.models.interface import Model, ModelProvider +from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel +from agents.run import RunConfig +from openai import AsyncOpenAI + +from strix.config.models import _NonStreamingModel + + +if TYPE_CHECKING: + from collections.abc import Iterator + + +_TURNS: list[dict[str, Any]] = [] + + +def _unknown_tool_call_completion() -> dict[str, Any]: + return { + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 0, + "model": "gw-model", + "choices": [ + { + "index": 0, + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "read_file", + "arguments": '{"path": "/etc/passwd"}', + }, + } + ], + }, + } + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7}, + } + + +def _text_completion(text: str) -> dict[str, Any]: + return { + "id": "chatcmpl-2", + "object": "chat.completion", + "created": 0, + "model": "gw-model", + "choices": [ + {"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": text}} + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}, + } + + +class _Handler(BaseHTTPRequestHandler): + """Calls an unregistered tool on turn 1, then answers on turn 2.""" + + def log_message(self, *args: Any) -> None: + pass + + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", 0)) + _TURNS.append(json.loads(self.rfile.read(length) or b"{}")) + completion = ( + _unknown_tool_call_completion() if len(_TURNS) == 1 else _text_completion("recovered") + ) + payload = json.dumps(completion).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + +@pytest.fixture +def gateway_url() -> Iterator[str]: + _TURNS.clear() + server = HTTPServer(("127.0.0.1", 0), _Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_address[1]}/v1" + finally: + server.shutdown() + server.server_close() + + +def _agent() -> Agent[Any]: + @function_tool + def real_tool(n: int) -> str: + return f"did {n}" + + return Agent(name="Strix", instructions="test", tools=[real_tool], model="gw-model") + + +def _run_config(base_url: str, **kwargs: Any) -> RunConfig: + class _Provider(ModelProvider): + def get_model(self, model_name: str | None) -> Model: # noqa: ARG002 + client = AsyncOpenAI(api_key="tok", base_url=base_url) + return _NonStreamingModel(OpenAIChatCompletionsModel("gw-model", openai_client=client)) + + return RunConfig(model_provider=_Provider(), **kwargs) + + +@pytest.mark.asyncio +async def test_unknown_tool_call_is_returned_to_the_model(gateway_url: str) -> None: + result = Runner.run_streamed( + _agent(), + input="go", + run_config=_run_config(gateway_url, tool_not_found_behavior="return_error_to_model"), + ) + async for _ in result.stream_events(): + pass + + assert result.final_output == "recovered" + # The second turn carries the error back to the model as a tool result. + tool_results = [ + item + for item in _TURNS[1]["messages"] + if item.get("role") == "tool" and item.get("tool_call_id") == "call_1" + ] + assert tool_results + assert "read_file" in str(tool_results[0]["content"]) + + +@pytest.mark.asyncio +async def test_unknown_tool_call_kills_the_run_without_the_setting(gateway_url: str) -> None: + result = Runner.run_streamed(_agent(), input="go", run_config=_run_config(gateway_url)) + with pytest.raises(ModelBehaviorError, match="read_file"): + async for _ in result.stream_events(): + pass diff --git a/uv.lock b/uv.lock index 8b933b4a..b98bac5c 100644 --- a/uv.lock +++ b/uv.lock @@ -1384,7 +1384,7 @@ wheels = [ [[package]] name = "openai" -version = "2.44.0" +version = "2.53.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1396,14 +1396,14 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/f5/7c7cb955305cb41f7f3c5fd7e0e38bf6bbf2658468863d4b7b868a5cb8df/openai-2.44.0.tar.gz", hash = "sha256:68a5a5ffad82b8ff7d451c437529fb64f7c3b8123aaf0c021966a882d9e3947d", size = 988753, upload-time = "2026-06-24T20:56:02.293Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/cf/36e3e7235fdf6d125c052acc0970924611b17a20a4fe580596faf4566a65/openai-2.53.0.tar.gz", hash = "sha256:baf5802ad08980e1d9d561e1b996e800c8bcd14af5847c6d0e7a5cc59e4d4116", size = 1099435, upload-time = "2026-08-03T21:42:01.664Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/f4/561ed79fd94876160018a5e75254cfcb9b0e62d4dded9dcb20072e86d623/openai-2.44.0-py3-none-any.whl", hash = "sha256:0a2a3ab2e29aeda368700f662ff9ba0f9df17ba4c54577a64e08b8115a3cc0ad", size = 1366216, upload-time = "2026-06-24T20:55:58.882Z" }, + { url = "https://files.pythonhosted.org/packages/78/0f/cc6afea3542a5142c5d8fc8211c5e059a8375105d004a41dfa2c7948dbb0/openai-2.53.0-py3-none-any.whl", hash = "sha256:c694ffc747a3c4d1663ef2b07b811315a476164ee5efa3a993967349ebca7618", size = 1659829, upload-time = "2026-08-03T21:41:59.581Z" }, ] [[package]] name = "openai-agents" -version = "0.14.6" +version = "0.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "griffelib" }, @@ -1411,13 +1411,12 @@ dependencies = [ { name = "openai" }, { name = "pydantic" }, { name = "requests" }, - { name = "types-requests" }, { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/fe/4f859d13ba5eea5fe5a3166ffeed04bd04d478ccf3187da6acebb17ba2a7/openai_agents-0.14.6.tar.gz", hash = "sha256:e9d16b835f73be4c5e3798694f90d7a62efcade931e59416bc7462c850e15705", size = 5311175, upload-time = "2026-04-25T02:32:00.897Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6c/8fa83cb23d2fe864b284cb45acf895d72a2f6e9827cc684dd4ef0d02d414/openai_agents-0.19.0.tar.gz", hash = "sha256:1d519d6966834e5c04160caec3a2549190e92ce50cdeb22fac5e17e67b8b98b2", size = 5620718, upload-time = "2026-07-27T22:49:26.615Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/96/b49d04e860c79699814289c273e88066ce97a50686172b5733b7458da062/openai_agents-0.14.6-py3-none-any.whl", hash = "sha256:fdd3fb459892c8af5d0b522908b544e96f6217c7254ba55e966424493b43c1ed", size = 816112, upload-time = "2026-04-25T02:31:58.976Z" }, + { url = "https://files.pythonhosted.org/packages/3b/3a/bac1aa3405c0f11b4334ac999e881d08fa40fad1f3b7229a1ca222ada489/openai_agents-0.19.0-py3-none-any.whl", hash = "sha256:25392cff993eca7c75b0679ec8d0111faef20c517222c0193111097d0f70db7a", size = 928463, upload-time = "2026-07-27T22:49:24.405Z" }, ] [package.optional-dependencies] @@ -2426,8 +2425,8 @@ requires-dist = [ { name = "docker", specifier = ">=7.1.0" }, { name = "google-auth", marker = "extra == 'vertex'", specifier = ">=2.0.0" }, { name = "litellm" }, - { name = "openai", specifier = ">=2.26.0,<2.45" }, - { name = "openai-agents", extras = ["litellm"], specifier = "==0.14.6" }, + { name = "openai", specifier = ">=2.45.0,<3" }, + { name = "openai-agents", extras = ["litellm"], specifier = ">=0.19.0,<0.20" }, { name = "pydantic", specifier = ">=2.11.3" }, { name = "pydantic-settings", specifier = ">=2.13.0" }, { name = "pypdf", specifier = ">=5.0" }, @@ -2550,18 +2549,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, ] -[[package]] -name = "types-requests" -version = "2.33.0.20260518" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752, upload-time = "2026-05-18T06:07:37.966Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0", size = 21391, upload-time = "2026-05-18T06:07:37.044Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0" From 3bcf3778f0a32ee2161923f5821fa9ea5d4ae49a Mon Sep 17 00:00:00 2001 From: Ahmed Allam <49919286+0xallam@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:21:33 +0000 Subject: [PATCH 31/57] fix(core): settle a non-interactive agent's status before its exception unwinds An exception escaping a non-interactive cycle re-raised before the status handling, so a dying child stayed 'running' and its parent waited out the timeout on a completion report the child could no longer send. Set the terminal status and wake the parent on the way out too. --- strix/core/execution.py | 10 +++++++--- tests/test_execution.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/strix/core/execution.py b/strix/core/execution.py index 8966ebf4..eccc3419 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -780,17 +780,21 @@ async def _run_cycle( # noqa: PLR0912, PLR0915 await coordinator.set_status(agent_id, "failed", error=str(exc)) await notify_parent_on_terminal(coordinator, agent_id, "failed") return None - if not interactive: - raise if isinstance(exc, MaxTurnsExceeded): status: Status = "stopped" elif isinstance(exc, UserError | AgentsException | APIError): status = "failed" else: status = "crashed" - logger.exception("agent run failed for %s; parking as %s", agent_id, status) + logger.exception("agent run failed for %s; marking %s", agent_id, status) + # Settle the status and wake the parent before the exception unwinds a + # non-interactive agent's task: a child that dies still owes its parent a + # report, and the parent would otherwise wait out its timeout on a message + # the dead child can no longer send. await coordinator.set_status(agent_id, status, error=str(exc) or type(exc).__name__) await notify_parent_on_terminal(coordinator, agent_id, status) + if not interactive: + raise return None else: return cast("RunResultBase | None", stream) diff --git a/tests/test_execution.py b/tests/test_execution.py index da6f4169..6364f801 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -855,6 +855,39 @@ async def test_structured_provider_refusal_fails_noninteractive_child( session.close() +@pytest.mark.asyncio +async def test_crashing_noninteractive_child_settles_and_wakes_its_parent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # The exception ends the child's task, so its status and the parent's wake-up + # have to be settled on the way out or the parent waits on a dead child. + def _boom(*_args: Any, **_kwargs: Any) -> Any: + raise RuntimeError("sandbox died mid-turn") + + monkeypatch.setattr("strix.core.execution.Runner.run_streamed", _boom) + coordinator = AgentCoordinator() + await coordinator.register("root", "strix", parent_id=None) + await coordinator.register("child", "recon", parent_id="root") + + with pytest.raises(RuntimeError, match="sandbox died mid-turn"): + await execution._run_cycle( + MagicMock(), + coordinator, + "child", + input_data="task", + run_config=MagicMock(), + context={"parent_id": "root"}, + max_turns=5, + session=None, + interactive=False, + event_sink=None, + hooks=None, + ) + + assert coordinator.statuses["child"] == "crashed" + assert coordinator.pending_counts.get("root", 0) > 0 + + @pytest.mark.asyncio async def test_run_agent_loop_seeds_identity_before_first_cycle( tmp_path: Any, monkeypatch: pytest.MonkeyPatch From ea6d53f4e9d3f1da4884c6c27cc81ebd16a0f29d Mon Sep 17 00:00:00 2001 From: Ahmed Allam <49919286+0xallam@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:02:24 +0000 Subject: [PATCH 32/57] build: add types-requests to dev deps The old openai-agents pin pulled types-requests in transitively; 0.19.0 does not, so mypy lost the requests stubs. Depend on them directly. --- pyproject.toml | 1 + uv.lock | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 6a2ac4b0..663cc31f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ dev = [ "pyinstaller>=6.17.0; python_version >= '3.12' and python_version < '3.15'", "pytest>=8.3", "pytest-asyncio>=0.24", + "types-requests>=2.32", ] [tool.pytest.ini_options] diff --git a/uv.lock b/uv.lock index b98bac5c..72871c73 100644 --- a/uv.lock +++ b/uv.lock @@ -2414,6 +2414,7 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, + { name = "types-requests" }, ] [package.metadata] @@ -2446,6 +2447,7 @@ dev = [ { name = "pytest", specifier = ">=8.3" }, { name = "pytest-asyncio", specifier = ">=0.24" }, { name = "ruff", specifier = ">=0.11.13" }, + { name = "types-requests", specifier = ">=2.32" }, ] [[package]] @@ -2549,6 +2551,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, ] +[[package]] +name = "types-requests" +version = "2.33.0.20260712" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/51/703318f7b7be8bee126ec13bf615050f932d0179b8784420f3a0199cc769/types_requests-2.33.0.20260712.tar.gz", hash = "sha256:2141b67ab534a5c5cd2dac5034f2a35f42e699c5bf185eee608c5246a069d7fb", size = 25084, upload-time = "2026-07-12T05:14:20.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/e7/010c87f559e216d83f9dc51e939633fd0d0ead3377340181ab0e223cd3b5/types_requests-2.33.0.20260712-py3-none-any.whl", hash = "sha256:de027e28c171d3da529689cbfa023b0b4eab188c8dfa22fd834eebd2cee6e7bb", size = 21392, upload-time = "2026-07-12T05:14:19.616Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From 6719a70611a09b45313d36f714a91d2d2e78bad6 Mon Sep 17 00:00:00 2001 From: Anurag Mewar <51066119+5h4d0wr007@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:37:44 +0530 Subject: [PATCH 33/57] feat: support API specs and Postman collections as targets (#866) --- README.md | 22 ++ docs/advanced/configuration.mdx | 4 + docs/usage/cli.mdx | 14 +- pyproject.toml | 2 + strix/config/settings.py | 5 + strix/core/inputs.py | 72 ++++-- strix/interface/cli_args.py | 14 +- strix/interface/scan_setup.py | 44 +++- strix/interface/utils.py | 95 +++++++- strix/skills/custom/api_spec_testing.md | 61 +++++ strix/utils/api_spec.py | 312 ++++++++++++++++++++++++ tests/test_api_spec.py | 290 ++++++++++++++++++++++ tests/test_api_spec_targets.py | 152 ++++++++++++ uv.lock | 2 + 14 files changed, 1069 insertions(+), 20 deletions(-) create mode 100644 strix/skills/custom/api_spec_testing.md create mode 100644 strix/utils/api_spec.py create mode 100644 tests/test_api_spec.py create mode 100644 tests/test_api_spec_targets.py diff --git a/README.md b/README.md index a8dea067..8e03a264 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,28 @@ strix --target https://github.com/org/repo strix --target https://your-app.com ``` +### API Testing (OpenAPI / Swagger / Postman) + +Point Strix at an API contract and it tests every declared endpoint instead of +having to discover them by crawling. Pair the spec with the live base URL so the +agent knows where to send traffic: + +```bash +# OpenAPI / Swagger file (.json / .yaml) +strix --target ./openapi.yaml --target https://api.your-app.com + +# Postman collection export +strix --target ./collection.postman_collection.json --target https://api.your-app.com + +# Postman collection pulled live by id (no manual export) +export POSTMAN_API_KEY="PMAK-..." +strix --target postman:// + +# ...with a Postman environment to resolve {{baseUrl}} / token variables +strix --target "postman://?env=" +``` + + ### Advanced Testing Scenarios ```bash diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index de88f659..f83cb4a7 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -80,6 +80,10 @@ affecting the agents that do the actual testing. API key for Perplexity AI. Enables real-time web search during scans for OSINT and vulnerability research.
+ + Postman API key (`PMAK-…`). Enables fetching Postman collections by id as a target (`postman://`), and Postman environments (`postman://?env=`) to resolve collection variables. Not needed when passing a local collection export file. + + Telemetry toggle. Set to `0`, `false`, `no`, or `off` to disable telemetry (PostHog, Scarf, OTEL). diff --git a/docs/usage/cli.mdx b/docs/usage/cli.mdx index 47d29e82..443c2edc 100644 --- a/docs/usage/cli.mdx +++ b/docs/usage/cli.mdx @@ -12,11 +12,17 @@ strix (--target | --target-list ) [options] ## Options - Target to test. Accepts URLs, repositories, local directories, domains, or IP addresses. Can be specified multiple times. Fresh runs require at least one target source: `--target` or `--target-list`. + Target to test. Accepts URLs, repositories, local directories, domains, IP addresses, API spec files (OpenAPI/Swagger `.json`/`.yaml`, a Postman collection export), or a live Postman collection by id (`postman://`). Can be specified multiple times. Fresh runs require at least one target source: `--target` or `--target-list`. + + When the target is an API spec, Strix copies it into the agent's workspace and authorizes the base URLs it declares (including those resolved from a Postman environment) as in-scope hosts - so the agent reads the contract and tests the full declared surface instead of discovering endpoints by crawling. Pair the spec with the deployed base URL (e.g. `--target ./openapi.yaml --target https://api.example.com`) so the agent has a reachable host to attack. A local directory is mounted into the sandbox live and **writable**, so the agent edits your real files (`.git` excepted). Commit or stash first. + + + Fetching a Postman collection by id requires `POSTMAN_API_KEY`. Add `?env=` to also pull a Postman environment, which resolves `{{baseUrl}}` / token variables the collection references (e.g. `postman://?env=`). + @@ -128,6 +134,12 @@ strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main # Multi-target white-box testing strix -t https://github.com/org/app -t https://staging.example.com +# API spec + live target (OpenAPI/Swagger file or Postman collection) +strix -t ./openapi.yaml -t https://api.example.com + +# Postman collection pulled live by id (+ optional environment) +strix -t "postman://?env=" + # Targets from a file strix --target-list ./targets.txt ``` diff --git a/pyproject.toml b/pyproject.toml index 663cc31f..38d5154c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dependencies = [ # Cap <49: 49.x drops the universal2 macOS wheel (arm64-only), which breaks # the Intel macOS (macos-x86_64) release build's `uv sync --frozen`. "cryptography>=48.0.1,<49", + "pyyaml>=6.0", ] [project.optional-dependencies] @@ -133,6 +134,7 @@ module = [ "pydantic_settings.*", "reportlab.*", "pypdf.*", + "yaml.*", "pygments.*", ] ignore_missing_imports = true diff --git a/strix/config/settings.py b/strix/config/settings.py index 3dc941d9..eda4ebce 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -122,6 +122,11 @@ class IntegrationSettings(BaseSettings): alias="PERPLEXITY_API_KEY", repr=False, ) + postman_api_key: str | None = Field( + default=None, + alias="POSTMAN_API_KEY", + repr=False, + ) class ViewerSettings(BaseSettings): diff --git a/strix/core/inputs.py b/strix/core/inputs.py index 1cb65533..a89248b2 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -33,6 +33,50 @@ def _accepts_required_tool_choice(model_name: str | None) -> bool: return name.startswith("openai/") or is_known_openai_bare_model(name) +def _render_diff_scope(diff_scope: dict[str, Any]) -> list[str]: + """Render pull-request diff-scope constraints as root-task lines.""" + if not diff_scope.get("active"): + return [] + parts: list[str] = [ + "\n\nScope Constraints:", + "- Pull request diff-scope mode is active. Prioritize changed files " + "and use other files only for context.", + ] + for repo_scope in diff_scope.get("repos", []) or []: + label = repo_scope.get("workspace_subdir") or repo_scope.get("source_path") or "repository" + changed = repo_scope.get("analyzable_files_count", 0) + deleted = repo_scope.get("deleted_files_count", 0) + parts.append(f"- {label}: {changed} changed file(s) in primary scope") + if deleted: + parts.append(f"- {label}: {deleted} deleted file(s) are context-only") + return parts + + +def _render_api_spec(details: dict[str, Any]) -> list[str]: + """Render an API spec target as root-task lines. + + The spec itself is in the workspace, so the task points at the file and lets + the agent read the contract rather than restating a parsed summary of it. + """ + title = details.get("spec_title") or details.get("target_spec", "API") + workspace_path = details.get("workspace_path", "") + lines = [ + f"- {title} ({details.get('spec_format', 'api')} specification" + + (f", available at: {workspace_path}" if workspace_path else "") + + ")" + ] + if base_urls := details.get("base_urls") or []: + lines.append(" - Base URL(s): " + ", ".join(base_urls)) + lines.append( + " - Read the specification and test every operation it declares, using " + "its declared parameters, request bodies, and auth. Endpoints in the " + "specification are in scope even when nothing links to them. Load the " + "`api_spec_testing` skill for the methodology, or spawn a specialist " + "with it." + ) + return lines + + def build_root_task(scan_config: dict[str, Any]) -> str: targets = scan_config.get("targets", []) or [] diff_scope = scan_config.get("diff_scope") or {} @@ -43,6 +87,7 @@ def build_root_task(scan_config: dict[str, Any]) -> str: "Local Codebases": [], "URLs": [], "IP Addresses": [], + "API Specifications": [], } for target in targets: @@ -68,6 +113,8 @@ def build_root_task(scan_config: dict[str, Any]) -> str: sections["URLs"].append(f"- {details.get('target_url', '')}") elif ttype == "ip_address": sections["IP Addresses"].append(f"- {details.get('target_ip', '')}") + elif ttype == "api_spec": + sections["API Specifications"].extend(_render_api_spec(details)) parts: list[str] = [] for label, items in sections.items(): @@ -92,21 +139,7 @@ def build_root_task(scan_config: dict[str, Any]) -> str: "truth for what to do." ) - if diff_scope.get("active"): - parts.append("\n\nScope Constraints:") - parts.append( - "- Pull request diff-scope mode is active. Prioritize changed files " - "and use other files only for context.", - ) - for repo_scope in diff_scope.get("repos", []) or []: - label = ( - repo_scope.get("workspace_subdir") or repo_scope.get("source_path") or "repository" - ) - changed = repo_scope.get("analyzable_files_count", 0) - deleted = repo_scope.get("deleted_files_count", 0) - parts.append(f"- {label}: {changed} changed file(s) in primary scope") - if deleted: - parts.append(f"- {label}: {deleted} deleted file(s) are context-only") + parts.extend(_render_diff_scope(diff_scope)) task = " ".join(parts) if user_instructions: @@ -121,6 +154,7 @@ def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]: "local_code": "target_path", "web_application": "target_url", "ip_address": "target_ip", + "api_spec": "target_spec", } for target in scan_config.get("targets", []) or []: ttype = target.get("type", "unknown") @@ -134,6 +168,14 @@ def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]: {"type": ttype, "value": value, "workspace_path": workspace_path}, ) + # An API spec authorizes the hosts it declares as in-scope web targets + # so the agent can exercise every endpoint without expanding scope. + if ttype == "api_spec": + authorized.extend( + {"type": "web_application", "value": base_url, "workspace_path": ""} + for base_url in details.get("base_urls") or [] + ) + return { "scope_source": "system_scan_config", "authorization_source": "strix_platform_verified_targets", diff --git a/strix/interface/cli_args.py b/strix/interface/cli_args.py index dce62d2c..ec4082cc 100644 --- a/strix/interface/cli_args.py +++ b/strix/interface/cli_args.py @@ -65,6 +65,14 @@ Examples: # Local code analysis strix --target ./my-project + # API spec test (OpenAPI/Swagger file or Postman collection export) + strix --target ./openapi.yaml --target https://api.example.com + strix --target ./collection.postman_collection.json + + # Postman collection pulled live by id (needs POSTMAN_API_KEY); optional environment + strix --target postman:// --target https://api.example.com + strix --target "postman://?env=" + # Domain penetration test strix --target example.com @@ -107,8 +115,10 @@ Examples: "--target", type=str, action="append", - help="Target to test (URL, repository, local directory path, domain name, or IP address). " - "Local directories are mounted into the sandbox writable. " + help="Target to test: URL, repository, local directory path, domain name, IP address, " + "an API spec file (OpenAPI/Swagger .json/.yaml or a Postman collection export), or a " + "Postman collection by id (postman://[?env=], needs " + "POSTMAN_API_KEY). Local directories are mounted into the sandbox writable. " "Can be specified multiple times for multi-target scans. " "Fresh runs require --target or --target-list.", ) diff --git a/strix/interface/scan_setup.py b/strix/interface/scan_setup.py index 76353bfa..327769a3 100644 --- a/strix/interface/scan_setup.py +++ b/strix/interface/scan_setup.py @@ -12,7 +12,7 @@ from __future__ import annotations import asyncio import logging from datetime import UTC, datetime -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from strix.config import Settings, codex, load_settings from strix.core.paths import run_dir_for @@ -28,8 +28,18 @@ from strix.interface.utils import ( read_target_list_file, resolve_diff_scope_context, rewrite_localhost_targets, + stage_api_specs, + write_fetched_collection, ) from strix.telemetry import posthog, scarf +from strix.utils.api_spec import ( + SpecParseError, + fetch_postman_collection, + fetch_postman_environment, + load_spec, + spec_base_urls, + spec_title, +) if TYPE_CHECKING: @@ -109,6 +119,9 @@ def build_targets_info(args: argparse.Namespace) -> None: else: display_target = target + if target_type == "api_spec": + _resolve_api_spec(target, target_dict) + args.targets_info.append( {"type": target_type, "details": target_dict, "original": display_target} ) @@ -119,6 +132,34 @@ def build_targets_info(args: argparse.Namespace) -> None: rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME) +def _resolve_api_spec(target: str, details: dict[str, Any]) -> None: + """Read the spec up front so bad input fails before the run starts. + + Records the declared base URLs (the only thing scope authorization can take + from a spec) and, for a ``postman://`` target, downloads the collection to a + local file so the sandbox never needs the Postman API key. + """ + try: + if details.get("source") == "postman_api": + collection_uid = str(details["collection_uid"]) + api_key = load_settings().integrations.postman_api_key or "" + raw = fetch_postman_collection(collection_uid, api_key) + environment_uid = str(details.get("environment_uid") or "") + extra_variables = ( + fetch_postman_environment(environment_uid, api_key) if environment_uid else None + ) + details["target_spec"] = write_fetched_collection(raw, collection_uid) + else: + raw = load_spec(str(details["target_spec"])) + extra_variables = None + base_urls = spec_base_urls(raw, extra_variables=extra_variables) + except SpecParseError as exc: + raise ValueError(f"Invalid API spec '{target}': {exc}") from None + + details["spec_title"] = spec_title(raw) + details["base_urls"] = base_urls + + def prepare_run(args: argparse.Namespace) -> None: """Resolve the run name, clone repos, compute diff-scope, and persist state. @@ -139,6 +180,7 @@ def prepare_run(args: argparse.Namespace) -> None: target_info["details"]["cloned_repo_path"] = cloned_path args.local_sources = collect_local_sources(args.targets_info) + args.local_sources.extend(stage_api_specs(args.targets_info, args.run_name)) diff_scope = resolve_diff_scope_context( local_sources=args.local_sources, scope_mode=args.scope_mode, diff --git a/strix/interface/utils.py b/strix/interface/utils.py index c70e45cf..e019a06a 100644 --- a/strix/interface/utils.py +++ b/strix/interface/utils.py @@ -11,7 +11,7 @@ import tempfile from dataclasses import dataclass, field from pathlib import Path from typing import Any -from urllib.parse import urlparse +from urllib.parse import parse_qs, urlparse import docker import requests @@ -21,6 +21,7 @@ from rich.panel import Panel from rich.text import Text from strix.config import load_settings +from strix.utils.api_spec import detect_spec_format logger = logging.getLogger(__name__) @@ -484,6 +485,15 @@ def _derive_target_label_for_run_name(targets_info: list[dict[str, Any]] | None) if target_type == "ip_address": return str(details.get("target_ip", original) or original) + if target_type == "api_spec": + if details.get("source") == "postman_api": + return "postman-collection" + spec_path = details.get("target_spec", original) + try: + return str(Path(spec_path).stem or spec_path) + except Exception: + return str(spec_path) + return str(original or "pentest") @@ -1113,6 +1123,24 @@ def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR09 return "repository", {"target_repo": target} parsed = urlparse(target) + if parsed.scheme == "postman": + collection_uid = f"{parsed.netloc}{parsed.path}".strip("/") + if not collection_uid: + raise ValueError( + f"Missing Postman collection id in '{target}' (expected postman://)" + ) + details = { + "target_spec": target, + "spec_format": "postman", + "source": "postman_api", + "collection_uid": collection_uid, + } + query = parse_qs(parsed.query) + env_uid = (query.get("env") or query.get("environment") or [""])[0].strip() + if env_uid: + details["environment_uid"] = env_uid + return "api_spec", details + if parsed.scheme in ("http", "https"): if parsed.username or parsed.password: return "repository", {"target_repo": target} @@ -1138,6 +1166,12 @@ def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR09 if path.is_dir(): check_mountable_dir(path) return "local_code", {"target_path": str(path.resolve())} + spec_format = detect_spec_format(path) + if spec_format is not None: + return "api_spec", { + "target_spec": str(path.resolve()), + "spec_format": spec_format, + } raise ValueError(f"Path exists but is not a directory: {target}") except (OSError, RuntimeError) as e: raise ValueError(f"Invalid path: {target} - {e!s}") from e @@ -1164,6 +1198,9 @@ def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR09 "- A valid URL (http:// or https://)\n" "- A Git repository URL (https://host/org/repo or git@host:org/repo.git)\n" "- A local directory path\n" + "- An API spec file (OpenAPI/Swagger .json/.yaml or a Postman collection)\n" + "- A Postman collection by id (postman://[?env=], " + "needs POSTMAN_API_KEY)\n" "- A domain name (e.g., example.com)\n" "- An IP address (e.g., 192.168.1.10)" ) @@ -1438,6 +1475,62 @@ def rewrite_localhost_targets(targets_info: list[dict[str, Any]], host_gateway: details["target_ip"] = host_gateway +#: API spec targets are copied into one workspace directory rather than mounted +#: from wherever they happen to live on the host. +API_SPEC_WORKSPACE_SUBDIR = "api-specs" + + +def write_fetched_collection(collection: dict[str, Any], collection_uid: str) -> str: + """Write a collection fetched from the Postman API to a local file. + + Returns the file path, so a ``postman://`` target continues as an ordinary + spec file from here on and the API key never leaves the host. + """ + staging = Path(tempfile.gettempdir()) / "strix_api_specs" / "fetched" + staging.mkdir(parents=True, exist_ok=True) + path = staging / f"{sanitize_name(collection_uid)}.postman_collection.json" + path.write_text(json.dumps(collection, indent=2), encoding="utf-8") + return str(path) + + +def stage_api_specs(targets_info: list[dict[str, Any]], run_name: str) -> list[dict[str, Any]]: + """Copy every ``api_spec`` target into one directory for the sandbox. + + A spec is a single file the agent reads, not a tree it works in, so it is + copied to a per-run staging directory that is exposed at + ``/workspace/api-specs`` instead of mounting its host location. Each target's + ``workspace_path`` records where the agent will find it. + """ + specs = [t for t in targets_info if t.get("type") == "api_spec"] + if not specs: + return [] + + staging = Path(tempfile.gettempdir()) / "strix_api_specs" / run_name + staging.mkdir(parents=True, exist_ok=True) + + used: set[str] = set() + for target in specs: + details = target["details"] + source = Path(str(details["target_spec"])) + name = source.name + stem, suffix = source.stem, source.suffix + count = 1 + while name in used: + count += 1 + name = f"{stem}-{count}{suffix}" + used.add(name) + shutil.copy2(source, staging / name) + details["workspace_path"] = f"/workspace/{API_SPEC_WORKSPACE_SUBDIR}/{name}" + + return [ + { + "source_path": str(staging), + "workspace_subdir": API_SPEC_WORKSPACE_SUBDIR, + "protect_metadata": False, + } + ] + + def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None) -> str: console = Console() diff --git a/strix/skills/custom/api_spec_testing.md b/strix/skills/custom/api_spec_testing.md new file mode 100644 index 00000000..d3d9285b --- /dev/null +++ b/strix/skills/custom/api_spec_testing.md @@ -0,0 +1,61 @@ +--- +name: api_spec_testing +description: Spec-driven API pentesting — systematically exercise every endpoint from an ingested OpenAPI/Swagger/Postman inventory for authz, injection, and business-logic flaws +--- + +# API Spec Testing + +When a target is an API specification (OpenAPI 3.x, Swagger 2.0, or a Postman +collection), the root task lists it under **API Specifications** with the path +to the spec file in the workspace and the authorized base URL(s). Read the spec +file first and build your own endpoint inventory from it — every operation with +its method, path, parameters, request-body schema (resolve `$ref`/`allOf`), and +auth scheme. Do not rediscover the surface by crawling. Walk the inventory +operation-by-operation and prove findings against the live base URL(s), which +are authorized in scope. + +## Methodology + +**1. Baseline the contract.** For each endpoint, send a well-formed request that +matches the declared schema and record the normal response (status, shape, +auth requirement). This baseline is what every abuse case is compared against. + +**2. Enumerate coverage.** Track every `METHOD path` in the inventory and mark it +tested. Undocumented-but-implied siblings are worth probing too (e.g. if +`GET /users/{id}` exists, try `PUT`/`DELETE`/`PATCH` on the same path even when +the spec omits them — specs routinely under-document write operations). + +**3. Prioritize by risk.** Object-scoped reads/writes, exports, admin/staff +operations, and anything touching billing, auth, or PII first. + +## What to test per endpoint + +Test the full range of API weaknesses against each operation, driven by what the +contract reveals — do not treat the following as an exhaustive checklist. The +highest-yield classes on APIs are **authorization** flaws, since the spec hands +you the object identifiers and privilege boundaries to abuse: examples include +BOLA/IDOR (swap `{id}`/`accountId`/`tenantId` across two accounts), BFLA +(privileged operations with a lower-privilege token), and missing/broken auth +(replay with the token stripped or expired against endpoints whose declared auth +says one is required). Beyond authorization, use the declared parameters and +body schema as a launch point for mass assignment and excessive data exposure, +injection and type-confusion on every parameter, and multi-step business-logic +and rate-limit abuse — and follow the contract wherever it suggests something +else worth probing. + +## Validation + +A finding is only real once reproduced against the live base URL with a +concrete request/response pair. Capture the exact HTTP request (method, path, +headers, body) and the response proving impact (another account's data, a +privileged action succeeding, an injected payload executing). Prefer two-account +diffs for authorization findings: same request, different token, unauthorized +success. + +## Tips + +- The base URL(s) from the spec are authorized targets — send real traffic. +- Path templates use `{param}`; substitute real values from your baseline. +- For Postman collections, saved example values and environment variables are + strong hints for valid inputs — use them to get past validation quickly. +- Keep a running coverage table so no operation in the inventory is skipped. diff --git a/strix/utils/api_spec.py b/strix/utils/api_spec.py new file mode 100644 index 00000000..db3a7738 --- /dev/null +++ b/strix/utils/api_spec.py @@ -0,0 +1,312 @@ +"""Recognize API specifications and extract the hosts they declare. + +Supports OpenAPI 3.x, Swagger 2.0, and Postman Collection v2.1. Two things about +an API spec must be decided on the host, in code: whether a target file is a +spec at all (detection), and which base URLs it authorizes as in-scope hosts +(scope cannot be self-granted by the agent). Everything else about the contract +— operations, parameters, request bodies, auth — is left to the agent, which +reads the spec file directly in the sandbox, so ``$ref``, ``allOf``, and nested +schemas resolve properly instead of being re-parsed here. Collections held only +in Postman are fetched here too, so the API key stays on the host and never +enters the sandbox. +""" + +from __future__ import annotations + +import json +import logging +import re +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +import requests +import yaml + + +logger = logging.getLogger(__name__) + + +SPEC_EXTENSIONS = frozenset({".json", ".yaml", ".yml"}) + +#: Guard against pathological Postman folder nesting. +_MAX_POSTMAN_DEPTH = 25 + + +class SpecParseError(ValueError): + """Raised when a spec cannot be read, recognized, or fetched.""" + + +def load_spec(path: str | Path) -> dict[str, Any]: + """Load an API spec file as a mapping. + + Raises :class:`SpecParseError` if the file cannot be read or is not a + JSON/YAML mapping. + """ + p = Path(path) + try: + text = p.read_text(encoding="utf-8") + except OSError as exc: + raise SpecParseError(f"Cannot read spec {p}: {exc}") from exc + # JSON is a subset of YAML, so safe_load parses both; try JSON first for a + # clearer error and to keep the fast path fast. + try: + data: Any = json.loads(text) + except json.JSONDecodeError: + try: + data = yaml.safe_load(text) + except yaml.YAMLError as exc: + raise SpecParseError(f"{p} is not valid JSON or YAML: {exc}") from exc + if not isinstance(data, dict): + raise SpecParseError(f"{p} does not contain a mapping at the top level") + return data + + +def classify_spec(raw: dict[str, Any]) -> str | None: + """Return ``openapi`` / ``swagger`` / ``postman``, or ``None`` if unrecognized.""" + if isinstance(raw.get("openapi"), str): + return "openapi" + if str(raw.get("swagger", "")).startswith("2"): + return "swagger" + info = raw.get("info") + if isinstance(info, dict) and ("_postman_id" in info or "item" in raw): + return "postman" + return None + + +def detect_spec_format(path: Path) -> str | None: + """Return the spec format of *path*, or ``None`` if it is not a spec. + + Only files whose extension is in :data:`SPEC_EXTENSIONS` are inspected; the + contents are then loaded to confirm, so an arbitrary ``.json`` config is not + mistaken for a spec. + """ + if path.suffix.lower() not in SPEC_EXTENSIONS: + return None + try: + raw = load_spec(path) + except SpecParseError: + return None + return classify_spec(raw) + + +def spec_title(raw: dict[str, Any]) -> str: + """Return the spec's declared name, for display in the task and run record.""" + info = raw.get("info") + if not isinstance(info, dict): + return "API" + name = info.get("title") or info.get("name") or "API" + return str(name).strip() or "API" + + +def _absolute_urls(candidates: list[str]) -> list[str]: + """Keep absolute http(s) URLs, without trailing slashes, in declared order.""" + urls: list[str] = [] + for candidate in candidates: + split = urlsplit(candidate.strip()) + if split.scheme in ("http", "https") and split.netloc: + urls.append(candidate.strip().rstrip("/")) + return list(dict.fromkeys(urls)) + + +_SERVER_VAR_PATTERN = re.compile(r"\{([^{}/]+)\}") + + +def _resolve_server_url(url: str, variables: Any) -> str: + """Substitute an OpenAPI server template's variables with their defaults.""" + if "{" not in url or not isinstance(variables, dict): + return url + defaults: dict[str, str] = {} + for name, spec in variables.items(): + if isinstance(spec, dict) and spec.get("default") is not None: + defaults[str(name)] = str(spec["default"]) + return _SERVER_VAR_PATTERN.sub(lambda m: defaults.get(m.group(1), m.group(0)), url) + + +def _openapi_base_urls(raw: dict[str, Any]) -> list[str]: + servers = raw.get("servers") + if not isinstance(servers, list): + return [] + return _absolute_urls( + [ + _resolve_server_url(str(server["url"]), server.get("variables")) + for server in servers + if isinstance(server, dict) and server.get("url") + ], + ) + + +def _swagger_base_urls(raw: dict[str, Any]) -> list[str]: + host = str(raw.get("host", "")).strip() + if not host: + return [] + base_path = str(raw.get("basePath", "")).strip() + schemes = [s for s in (raw.get("schemes") or ["https"]) if isinstance(s, str)] + return _absolute_urls([f"{scheme}://{host}{base_path}" for scheme in schemes]) + + +_POSTMAN_VAR_PATTERN = re.compile(r"\{\{\s*([^}]+?)\s*\}\}") + + +def postman_variables(raw: dict[str, Any]) -> dict[str, str]: + """Build a ``{name: value}`` map from a Postman ``variable`` block.""" + variables: dict[str, str] = {} + entries = raw.get("variable") + if isinstance(entries, list): + for entry in entries: + if isinstance(entry, dict) and entry.get("key") is not None: + variables[str(entry["key"])] = str(entry.get("value", "")) + return variables + + +def _resolve_postman_vars(text: str, variables: dict[str, str]) -> str: + if not variables or "{{" not in text: + return text + return _POSTMAN_VAR_PATTERN.sub(lambda m: variables.get(m.group(1), m.group(0)), text) + + +def _postman_request_url(url: Any, variables: dict[str, str]) -> str: + if isinstance(url, str): + raw = url + elif isinstance(url, dict): + raw = str(url.get("raw", "")) + if not raw: + host = url.get("host") + raw = ".".join(str(h) for h in host) if isinstance(host, list) else str(host or "") + else: + return "" + return _resolve_postman_vars(raw, variables) + + +def _walk_postman_hosts( + items: Any, + variables: dict[str, str], + hosts: list[str], + depth: int = 0, +) -> None: + if depth > _MAX_POSTMAN_DEPTH or not isinstance(items, list): + return + for node in items: + if not isinstance(node, dict): + continue + if isinstance(node.get("item"), list): + _walk_postman_hosts(node["item"], variables, hosts, depth + 1) + continue + request = node.get("request") + if not isinstance(request, dict): + continue + url = _postman_request_url(request.get("url"), variables) + split = urlsplit(url) + if split.scheme and split.netloc: + hosts.append(f"{split.scheme}://{split.netloc}") + + +def _postman_base_urls(raw: dict[str, Any], extra_variables: dict[str, str] | None) -> list[str]: + variables = postman_variables(raw) + if extra_variables: + variables.update(extra_variables) # environment values override collection defaults + hosts: list[str] = [] + _walk_postman_hosts(raw.get("item"), variables, hosts) + return _absolute_urls(sorted(set(hosts))) + + +def spec_base_urls( + raw: dict[str, Any], + *, + extra_variables: dict[str, str] | None = None, +) -> list[str]: + """Return the absolute base URLs a spec declares, for scope authorization. + + Relative and unresolved-template URLs are dropped: an unusable value would + otherwise be authorized as an in-scope host. Callers pair the spec with an + explicit ``--target`` host when the spec declares none. + """ + spec_format = classify_spec(raw) + if spec_format == "openapi": + return _openapi_base_urls(raw) + if spec_format == "swagger": + return _swagger_base_urls(raw) + if spec_format == "postman": + return _postman_base_urls(raw, extra_variables) + raise SpecParseError("File is not a recognized OpenAPI, Swagger, or Postman spec") + + +POSTMAN_API_BASE = "https://api.getpostman.com" +_POSTMAN_FETCH_TIMEOUT = 30 + + +def _postman_api_json(url: str, api_key: str, label: str) -> dict[str, Any]: + """GET a Postman API resource and return the parsed JSON payload. + + Raises :class:`SpecParseError` with an actionable message on auth, network, + or shape errors. + """ + if not api_key: + raise SpecParseError( + "POSTMAN_API_KEY is not set. Export a Postman API key (PMAK-…) to " + "fetch from the Postman API, or pass a local collection file instead.", + ) + try: + response = requests.get( + url, + headers={"X-Api-Key": api_key, "Accept": "application/json"}, + timeout=_POSTMAN_FETCH_TIMEOUT, + ) + except requests.RequestException as exc: + raise SpecParseError(f"Failed to reach the Postman API: {exc}") from exc + + if response.status_code == 401: + raise SpecParseError("Postman API rejected the key (401). Check POSTMAN_API_KEY.") + if response.status_code == 404: + raise SpecParseError( + f"Postman {label} not found (404). Check the id and that the key can access it.", + ) + if response.status_code != 200: + raise SpecParseError(f"Postman API returned HTTP {response.status_code} for {label}.") + try: + payload = response.json() + except ValueError as exc: + raise SpecParseError(f"Postman API returned non-JSON for {label}") from exc + if not isinstance(payload, dict): + raise SpecParseError(f"Unexpected Postman API response shape for {label}") + return payload + + +def fetch_postman_collection(collection_uid: str, api_key: str) -> dict[str, Any]: + """Fetch a collection from the Postman API and return the raw collection dict. + + Uses ``GET /collections/{uid}`` with the ``X-Api-Key`` header. The endpoint + wraps the collection under a ``collection`` key, unwrapped here so the result + matches an exported collection file. + """ + payload = _postman_api_json( + f"{POSTMAN_API_BASE}/collections/{collection_uid}", + api_key, + f"collection {collection_uid}", + ) + collection = payload.get("collection", payload) + if not isinstance(collection, dict) or not collection: + raise SpecParseError(f"Postman collection {collection_uid} came back empty") + return collection + + +def fetch_postman_environment(environment_uid: str, api_key: str) -> dict[str, str]: + """Fetch a Postman environment and return its enabled ``{key: value}`` pairs. + + Disabled values are skipped, matching how Postman resolves an environment at + request time. + """ + payload = _postman_api_json( + f"{POSTMAN_API_BASE}/environments/{environment_uid}", + api_key, + f"environment {environment_uid}", + ) + environment = payload.get("environment", payload) + values = environment.get("values") if isinstance(environment, dict) else None + if not isinstance(values, list): + return {} + return { + str(value["key"]): str(value.get("value", "")) + for value in values + if isinstance(value, dict) and value.get("key") and value.get("enabled", True) + } diff --git a/tests/test_api_spec.py b/tests/test_api_spec.py new file mode 100644 index 00000000..d7979d35 --- /dev/null +++ b/tests/test_api_spec.py @@ -0,0 +1,290 @@ +"""Tests for spec recognition and base-URL extraction in strix.utils.api_spec.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +import pytest +import requests +import yaml + +from strix.utils.api_spec import ( + SpecParseError, + classify_spec, + detect_spec_format, + fetch_postman_collection, + fetch_postman_environment, + load_spec, + spec_base_urls, + spec_title, +) + + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + +OPENAPI_YAML = """ +openapi: 3.0.1 +info: + title: Shop API + version: 1.0.0 +servers: + - url: https://{region}.api.shop.test/{ver} + variables: + region: + default: eu + ver: + default: v1 +paths: + /users/{id}: + get: + summary: Get user +""" + +SWAGGER_JSON = { + "swagger": "2.0", + "info": {"title": "Legacy"}, + "host": "legacy.test", + "basePath": "/api", + "schemes": ["https"], + "paths": {"/orders": {"post": {"summary": "Create order"}}}, +} + +POSTMAN_JSON = { + "info": {"_postman_id": "abc-123", "name": "Pet Store"}, + "item": [ + { + "name": "Pets", + "item": [ + { + "name": "List pets", + "request": {"method": "GET", "url": {"raw": "https://petstore.test/pets"}}, + } + ], + }, + { + "name": "Add pet", + "request": {"method": "POST", "url": "https://petstore.test/pets"}, + }, + ], +} + + +def _write(tmp_path: Path, name: str, content: str) -> Path: + path = tmp_path / name + path.write_text(content, encoding="utf-8") + return path + + +# --- detection ----------------------------------------------------------- + + +def test_detect_openapi_yaml(tmp_path: Path) -> None: + assert detect_spec_format(_write(tmp_path, "openapi.yaml", OPENAPI_YAML)) == "openapi" + + +def test_detect_swagger_json(tmp_path: Path) -> None: + assert detect_spec_format(_write(tmp_path, "swagger.json", json.dumps(SWAGGER_JSON))) == ( + "swagger" + ) + + +def test_detect_postman_json(tmp_path: Path) -> None: + assert detect_spec_format(_write(tmp_path, "collection.json", json.dumps(POSTMAN_JSON))) == ( + "postman" + ) + + +def test_detect_ignores_non_spec_extension(tmp_path: Path) -> None: + assert detect_spec_format(_write(tmp_path, "notes.txt", OPENAPI_YAML)) is None + + +def test_detect_ignores_non_spec_json(tmp_path: Path) -> None: + assert detect_spec_format(_write(tmp_path, "config.json", json.dumps({"foo": "bar"}))) is None + + +def test_classify_unrecognized_is_none() -> None: + assert classify_spec({"foo": 1}) is None + + +# --- loading ------------------------------------------------------------- + + +def test_load_spec_rejects_missing_file(tmp_path: Path) -> None: + with pytest.raises(SpecParseError, match="Cannot read"): + load_spec(tmp_path / "nope.yaml") + + +def test_load_spec_rejects_malformed_yaml(tmp_path: Path) -> None: + with pytest.raises(SpecParseError): + load_spec(_write(tmp_path, "broken.yaml", "openapi: 3.0.0\npaths: [unclosed")) + + +def test_load_spec_rejects_non_mapping(tmp_path: Path) -> None: + with pytest.raises(SpecParseError, match="mapping"): + load_spec(_write(tmp_path, "list.json", json.dumps([1, 2, 3]))) + + +def test_spec_title_reads_openapi_and_postman() -> None: + assert spec_title(yaml.safe_load(OPENAPI_YAML)) == "Shop API" + assert spec_title(POSTMAN_JSON) == "Pet Store" + assert spec_title({"info": {}}) == "API" + + +# --- base URL extraction ------------------------------------------------- + + +def test_openapi_base_urls_resolve_server_variables() -> None: + raw = yaml.safe_load(OPENAPI_YAML) + # {region}/{ver} substituted with their declared defaults + assert spec_base_urls(raw) == ["https://eu.api.shop.test/v1"] + + +def test_openapi_drops_unresolved_relative_server() -> None: + raw = {"openapi": "3.0.0", "info": {"title": "X"}, "servers": [{"url": "/v2"}]} + # relative URL is not an authorizable host + assert spec_base_urls(raw) == [] + + +def test_swagger_base_urls_built_from_host() -> None: + assert spec_base_urls(SWAGGER_JSON) == ["https://legacy.test/api"] + + +def test_swagger_without_host_yields_no_base_urls() -> None: + assert spec_base_urls({"swagger": "2.0", "info": {}, "paths": {}}) == [] + + +def test_postman_base_urls_from_request_hosts() -> None: + assert spec_base_urls(POSTMAN_JSON) == ["https://petstore.test"] + + +def test_spec_base_urls_rejects_unrecognized() -> None: + with pytest.raises(SpecParseError): + spec_base_urls({"foo": 1}) + + +# --- Postman variable / environment resolution --------------------------- + +POSTMAN_WITH_VARS = { + "info": {"_postman_id": "v-1", "name": "Var Collection"}, + "variable": [{"key": "baseUrl", "value": "https://api.vars.test"}], + "item": [ + {"name": "Get thing", "request": {"method": "GET", "url": {"raw": "{{baseUrl}}/things/1"}}} + ], +} + +POSTMAN_NEEDS_ENV = { + "info": {"_postman_id": "e-1", "name": "Env Collection"}, + "item": [ + {"name": "Get thing", "request": {"method": "GET", "url": {"raw": "{{baseUrl}}/things/1"}}} + ], +} + + +def test_postman_resolves_collection_variables() -> None: + assert spec_base_urls(POSTMAN_WITH_VARS) == ["https://api.vars.test"] + + +def test_postman_without_env_leaves_variable_unresolved() -> None: + # {{baseUrl}} never resolves -> no absolute host recovered + assert spec_base_urls(POSTMAN_NEEDS_ENV) == [] + + +def test_postman_environment_values_resolve_base_url() -> None: + resolved = spec_base_urls( + POSTMAN_NEEDS_ENV, + extra_variables={"baseUrl": "https://api.env.test"}, + ) + assert resolved == ["https://api.env.test"] + + +# --- Postman API fetch --------------------------------------------------- + + +class _FakeResponse: + def __init__(self, status_code: int, payload: Any) -> None: + self.status_code = status_code + self._payload = payload + + def json(self) -> Any: + return self._payload + + +def test_fetch_postman_collection_unwraps(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + + def fake_get(url: str, headers: dict[str, str], **_kwargs: Any) -> _FakeResponse: + captured["url"] = url + captured["headers"] = headers + return _FakeResponse(200, {"collection": POSTMAN_WITH_VARS}) + + monkeypatch.setattr(requests, "get", fake_get) + collection = fetch_postman_collection("abc-123", "PMAK-xyz") + + assert collection["info"]["name"] == "Var Collection" + assert captured["url"].endswith("/collections/abc-123") + assert captured["headers"]["X-Api-Key"] == "PMAK-xyz" + + +def test_fetch_postman_missing_key_raises() -> None: + with pytest.raises(SpecParseError, match="POSTMAN_API_KEY"): + fetch_postman_collection("abc-123", "") + + +def test_fetch_postman_404_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(requests, "get", lambda *_a, **_k: _FakeResponse(404, {})) + with pytest.raises(SpecParseError, match="not found"): + fetch_postman_collection("missing", "PMAK-xyz") + + +def test_fetch_postman_401_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(requests, "get", lambda *_a, **_k: _FakeResponse(401, {})) + with pytest.raises(SpecParseError, match="rejected the key"): + fetch_postman_collection("abc-123", "bad-key") + + +def test_fetch_postman_empty_collection_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(requests, "get", lambda *_a, **_k: _FakeResponse(200, {"collection": {}})) + with pytest.raises(SpecParseError, match="empty"): + fetch_postman_collection("abc-123", "PMAK-xyz") + + +def test_fetch_postman_environment_returns_enabled_values( + monkeypatch: pytest.MonkeyPatch, +) -> None: + payload = { + "environment": { + "name": "prod", + "values": [ + {"key": "baseUrl", "value": "https://api.env.test", "enabled": True}, + {"key": "secretToken", "value": "s3cr3t", "enabled": False}, + ], + } + } + monkeypatch.setattr(requests, "get", lambda *_a, **_k: _FakeResponse(200, payload)) + values = fetch_postman_environment("env-1", "PMAK-xyz") + assert values == {"baseUrl": "https://api.env.test"} # disabled secret excluded + + +def _dispatch_get( + collection: dict[str, Any], + env: dict[str, Any], +) -> Callable[..., _FakeResponse]: + def fake_get(url: str, **_kwargs: Any) -> _FakeResponse: + if "/environments/" in url: + return _FakeResponse(200, env) + return _FakeResponse(200, {"collection": collection}) + + return fake_get + + +def test_fetch_then_resolve_from_environment(monkeypatch: pytest.MonkeyPatch) -> None: + env = {"environment": {"values": [{"key": "baseUrl", "value": "https://api.env.test"}]}} + monkeypatch.setattr(requests, "get", _dispatch_get(POSTMAN_NEEDS_ENV, env)) + + collection = fetch_postman_collection("coll-1", "PMAK-xyz") + variables = fetch_postman_environment("env-1", "PMAK-xyz") + assert spec_base_urls(collection, extra_variables=variables) == ["https://api.env.test"] diff --git a/tests/test_api_spec_targets.py b/tests/test_api_spec_targets.py new file mode 100644 index 00000000..bba0aba5 --- /dev/null +++ b/tests/test_api_spec_targets.py @@ -0,0 +1,152 @@ +"""Integration of the ``api_spec`` target type into detection, staging, and inputs.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +import pytest + +from strix.core.inputs import build_root_task, build_scope_context +from strix.interface.scan_setup import build_targets_info +from strix.interface.utils import infer_target_type, stage_api_specs + + +OPENAPI = { + "openapi": "3.0.0", + "info": {"title": "Shop API", "version": "1"}, + "servers": [{"url": "https://api.shop.test/v1"}], + "paths": { + "/users/{id}": { + "get": { + "summary": "Get user", + "parameters": [{"name": "id", "in": "path", "schema": {"type": "string"}}], + } + } + }, +} + + +def _write_spec(directory: Path, name: str = "openapi.json") -> Path: + directory.mkdir(parents=True, exist_ok=True) + path = directory / name + path.write_text(json.dumps(OPENAPI), encoding="utf-8") + return path + + +def _resolved_targets(*spec_paths: Path) -> list[dict[str, Any]]: + """Run spec targets through the real setup path (detection + spec resolution).""" + args = argparse.Namespace(target=[str(p) for p in spec_paths], target_list=None) + build_targets_info(args) + targets: list[dict[str, Any]] = args.targets_info + return targets + + +def _staged_target(tmp_path: Path, run_name: str = "test-run") -> dict[str, Any]: + targets = _resolved_targets(_write_spec(tmp_path / "src")) + stage_api_specs(targets, run_name) + return targets[0] + + +def test_infer_target_type_detects_api_spec(tmp_path: Path) -> None: + path = _write_spec(tmp_path) + ttype, details = infer_target_type(str(path)) + assert ttype == "api_spec" + assert details["spec_format"] == "openapi" + assert Path(details["target_spec"]).is_absolute() + + +def test_infer_target_type_still_rejects_non_spec_file(tmp_path: Path) -> None: + path = tmp_path / "data.json" + path.write_text(json.dumps({"foo": "bar"}), encoding="utf-8") + with pytest.raises(ValueError, match="not a directory"): + infer_target_type(str(path)) + + +def test_infer_target_type_detects_postman_uri() -> None: + ttype, details = infer_target_type("postman://12345-abcdef-uid") + assert ttype == "api_spec" + assert details["source"] == "postman_api" + assert details["collection_uid"] == "12345-abcdef-uid" + assert details["spec_format"] == "postman" + + +def test_infer_target_type_rejects_empty_postman_uri() -> None: + with pytest.raises(ValueError, match="collection id"): + infer_target_type("postman://") + + +def test_infer_target_type_parses_postman_environment() -> None: + _ttype, details = infer_target_type("postman://coll-uid?env=env-uid") + assert details["collection_uid"] == "coll-uid" + assert details["environment_uid"] == "env-uid" + + +def test_infer_target_type_postman_without_env_omits_key() -> None: + _ttype, details = infer_target_type("postman://coll-uid") + assert "environment_uid" not in details + + +def test_build_targets_info_records_title_and_base_urls(tmp_path: Path) -> None: + (target,) = _resolved_targets(_write_spec(tmp_path)) + assert target["details"]["spec_title"] == "Shop API" + assert target["details"]["base_urls"] == ["https://api.shop.test/v1"] + + +def test_build_targets_info_rejects_unparseable_spec(tmp_path: Path) -> None: + path = tmp_path / "openapi.json" + path.write_text('{"openapi": "3.0.0", "info": {"title": "X"}, "paths"', encoding="utf-8") + args = argparse.Namespace(target=[str(path)], target_list=None) + # a broken file is not recognized as a spec, so it fails as an unusable target + with pytest.raises(ValueError, match="Invalid target"): + build_targets_info(args) + + +def test_stage_api_specs_copies_spec_into_workspace_dir(tmp_path: Path) -> None: + targets = _resolved_targets(_write_spec(tmp_path / "src")) + (source,) = stage_api_specs(targets, "stage-run") + + assert source["workspace_subdir"] == "api-specs" + staged = Path(source["source_path"]) / "openapi.json" + assert json.loads(staged.read_text(encoding="utf-8"))["info"]["title"] == "Shop API" + assert targets[0]["details"]["workspace_path"] == "/workspace/api-specs/openapi.json" + + +def test_stage_api_specs_disambiguates_same_filename(tmp_path: Path) -> None: + targets = _resolved_targets( + _write_spec(tmp_path / "a"), + _write_spec(tmp_path / "b"), + ) + (source,) = stage_api_specs(targets, "dupe-run") + + staged_paths = [t["details"]["workspace_path"] for t in targets] + assert staged_paths == [ + "/workspace/api-specs/openapi.json", + "/workspace/api-specs/openapi-2.json", + ] + assert (Path(source["source_path"]) / "openapi-2.json").is_file() + + +def test_stage_api_specs_without_specs_returns_nothing() -> None: + assert stage_api_specs([{"type": "web_application", "details": {}}], "run") == [] + + +def test_build_root_task_points_at_the_spec_file(tmp_path: Path) -> None: + task = build_root_task({"targets": [_staged_target(tmp_path)]}) + assert "API Specifications" in task + assert "Shop API (openapi specification" in task + assert "/workspace/api-specs/openapi.json" in task + assert "https://api.shop.test/v1" in task + assert "test every operation it declares" in task + + +def test_build_scope_context_authorizes_base_urls(tmp_path: Path) -> None: + context = build_scope_context({"targets": [_staged_target(tmp_path)]}) + authorized = context["authorized_targets"] + + types = {a["type"] for a in authorized} + assert "api_spec" in types + assert "web_application" in types + assert any(a["value"] == "https://api.shop.test/v1" for a in authorized) diff --git a/uv.lock b/uv.lock index 72871c73..399dc00e 100644 --- a/uv.lock +++ b/uv.lock @@ -2391,6 +2391,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pypdf" }, + { name = "pyyaml" }, { name = "reportlab" }, { name = "requests" }, { name = "rich" }, @@ -2431,6 +2432,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.11.3" }, { name = "pydantic-settings", specifier = ">=2.13.0" }, { name = "pypdf", specifier = ">=5.0" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "reportlab", specifier = ">=4.0" }, { name = "requests", specifier = ">=2.32.0" }, { name = "rich" }, From 82dcd3135702ff3d1b99d5f6f9214b85b264d138 Mon Sep 17 00:00:00 2001 From: Ahmed Allam <49919286+0xallam@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:26:22 +0300 Subject: [PATCH 34/57] fix(tui): keep a long error inside the status row (#970) --- .../interface/tui/internal/app/model_test.go | 117 ++++++++++++++++++ strix/interface/tui/internal/app/setup.go | 5 +- .../tui/internal/app/setup_log_test.go | 22 ++++ strix/interface/tui/internal/app/view.go | 52 ++++++-- 4 files changed, 188 insertions(+), 8 deletions(-) diff --git a/strix/interface/tui/internal/app/model_test.go b/strix/interface/tui/internal/app/model_test.go index 901db23f..e971d392 100644 --- a/strix/interface/tui/internal/app/model_test.go +++ b/strix/interface/tui/internal/app/model_test.go @@ -1169,3 +1169,120 @@ func TestChatContentRerendersOnWidthAndExpansionChange(t *testing.T) { } } } + +// A model or backend failure can be a wrapped exception hundreds of columns +// wide and several lines long. The status row is one line of the chat column, so +// an oversized one widens the whole column - JoinHorizontal pads every row to the +// widest - which pushed the sidebar off screen and wrapped the frame. +func TestLongErrorDoesNotBreakTheFrame(t *testing.T) { + model := New(nil) + model.width, model.height = 120, 24 + model.showSplash = false + model.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{ScanState: "running"})) + bootstrap := protocol.CollectionBootstrap{ + Collection: "agents", Revision: 1, Cursor: 0, NextCursor: 1, Done: true, + Items: []json.RawMessage{rawJSON(t, protocol.Agent{ID: "a0", Name: "Strix", Status: "running"})}, + } + model.handleEnvelope(protocol.Envelope{ + Version: protocol.Version, Type: "collection_bootstrap", Payload: rawJSON(t, bootstrap), + }) + model.errorText = "litellm.APIConnectionError: OpenrouterException - Connection error " + + "while calling https://openrouter.ai/api/v1/chat/completions: HTTPSConnectionPool" + + "(host='openrouter.ai', port=443): Max retries exceeded\nTraceback (most recent " + + "call last):\n File \"/x/y.py\", line 42, in send\n raise err" + model.resizeViewport() + + lines := strings.Split(model.View(), "\n") + if len(lines) > model.height { + t.Fatalf("frame is %d rows in a %d-row terminal", len(lines), model.height) + } + for i, line := range lines { + if width := ansi.StringWidth(line); width > model.width { + t.Fatalf("row %d is %d columns in a %d-column terminal", i, width, model.width) + } + } + // The sidebar has to survive: its panels are the right edge of the frame. + if !strings.Contains(ansi.Strip(model.View()), "Strix") { + t.Fatal("the agent tree was pushed out of the frame") + } +} + +func TestStatusMessageFlattensAndKeepsItsHint(t *testing.T) { + row := ansi.Strip(statusMessage("boom\nsecond line\twith tabs", red, " · Send message to resume", 60)) + + if strings.Contains(row, "\n") || strings.Contains(row, "\t") { + t.Fatalf("status row is not a single line: %q", row) + } + if !strings.HasSuffix(row, " · Send message to resume") { + t.Fatalf("the hint was lost: %q", row) + } + if !strings.Contains(row, "boom second line with tabs") { + t.Fatalf("the message was mangled: %q", row) + } + // A message far too long for the row keeps the hint readable. + long := ansi.Strip(statusMessage(strings.Repeat("x", 500), red, " · Send message to resume", 60)) + if width := ansi.StringWidth(long); width > 60 { + t.Fatalf("status message is %d columns, want at most 60", width) + } + if !strings.HasSuffix(long, " · Send message to resume") { + t.Fatalf("the hint was clipped away: %q", long) + } +} + +// The status row must be exactly as wide as the column it sits in, at every +// terminal size. A narrow terminal cannot fit the quit hint alongside any status +// text, and keeping it anyway made the row wider than the terminal. +func TestStatusRowIsExactlyItsWidth(t *testing.T) { + quitHint := lipgloss.NewStyle().Foreground(white).Render("ctrl-q") + + lipgloss.NewStyle().Foreground(dim).Render(" quit") + longMessage := lipgloss.NewStyle().Foreground(red).Render(strings.Repeat("boom ", 40)) + + for width := 1; width <= 60; width++ { + for _, testCase := range []struct { + name string + left, right string + }{ + {"empty", "", ""}, + {"hint only", "", quitHint}, + {"long message and hint", longMessage, quitHint}, + {"long message alone", longMessage, ""}, + } { + row := composeStatusRow(testCase.left, testCase.right, width) + if got := ansi.StringWidth(row); got != width { + t.Fatalf("%s at width %d rendered %d columns: %q", + testCase.name, width, got, ansi.Strip(row)) + } + if strings.Contains(row, "\n") { + t.Fatalf("%s at width %d spans rows", testCase.name, width) + } + } + } + if row := composeStatusRow("x", "y", 0); row != "" { + t.Fatalf("a zero-width row should be empty, got %q", row) + } +} + +// A running scan in a narrow terminal must not wrap the frame. +func TestNarrowTerminalKeepsTheFrameIntact(t *testing.T) { + for _, width := range []int{8, 10, 13, 14, 20, 40} { + model := New(nil) + model.width, model.height = width, 20 + model.showSplash = false + model.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{ScanState: "running"})) + bootstrap := protocol.CollectionBootstrap{ + Collection: "agents", Revision: 1, Cursor: 0, NextCursor: 1, Done: true, + Items: []json.RawMessage{rawJSON(t, protocol.Agent{ID: "a0", Name: "Strix", Status: "running"})}, + } + model.handleEnvelope(protocol.Envelope{ + Version: protocol.Version, Type: "collection_bootstrap", Payload: rawJSON(t, bootstrap), + }) + model.errorText = strings.Repeat("connection failed ", 20) + model.resizeViewport() + + for i, line := range strings.Split(model.View(), "\n") { + if got := ansi.StringWidth(line); got > width { + t.Fatalf("at width %d row %d is %d columns", width, i, got) + } + } + } +} diff --git a/strix/interface/tui/internal/app/setup.go b/strix/interface/tui/internal/app/setup.go index ec3b1361..4a6c4b18 100644 --- a/strix/interface/tui/internal/app/setup.go +++ b/strix/interface/tui/internal/app/setup.go @@ -214,8 +214,11 @@ func (m *Model) setupLogAppend(line string) { } // setupMsg appends a styled feedback line (success green, error red, notice dim). +// The log budgets rows by entry, so a message is flattened to one line first: a +// wrapped exception would otherwise render as several rows and push the launch +// column past the bottom of the terminal. func (m *Model) setupMsg(text string, style lipgloss.Style) { - m.setupLogAppend(style.Render(text)) + m.setupLogAppend(style.Render(flattenStatus(text))) } // setupLogRows is how many feedback lines the launch column shows before the diff --git a/strix/interface/tui/internal/app/setup_log_test.go b/strix/interface/tui/internal/app/setup_log_test.go index b737bcb8..603fbe1b 100644 --- a/strix/interface/tui/internal/app/setup_log_test.go +++ b/strix/interface/tui/internal/app/setup_log_test.go @@ -93,3 +93,25 @@ func TestFocusedPanelsCarryTheGreenBorder(t *testing.T) { } } } + +// A wrapped exception is several lines. The log budgets rows by entry, so it has +// to become one row or the launch column grows past the terminal. +func TestSetupLogKeepsMultiLineErrorsToOneRow(t *testing.T) { + model := New(nil) + model.width, model.height = 100, 26 + model.showSplash = false + model.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{SetupMode: true, ScanState: "setup"})) + model.setupMsg("boom\nTraceback (most recent call last):\n File \"x.py\", line 1\n raise", render.Col(red)) + model.resizeViewport() + + if entries := len(model.setupLog); entries != 1 { + t.Fatalf("one message became %d log entries", entries) + } + if strings.Contains(model.setupLog[0], "\n") { + t.Fatalf("log entry spans rows: %q", model.setupLog[0]) + } + lines := strings.Split(model.View(), "\n") + if len(lines) > model.height { + t.Fatalf("start screen is %d rows in a %d-row terminal", len(lines), model.height) + } +} diff --git a/strix/interface/tui/internal/app/view.go b/strix/interface/tui/internal/app/view.go index f50f4a46..dec2b09b 100644 --- a/strix/interface/tui/internal/app/view.go +++ b/strix/interface/tui/internal/app/view.go @@ -654,8 +654,7 @@ func (m Model) statusView(width int) string { case "waiting": left = lipgloss.NewStyle().Foreground(dim).Render("Send message to resume") if msg := agent.ErrorMessage; msg != "" { - left = lipgloss.NewStyle().Foreground(red).Render(msg) + - lipgloss.NewStyle().Foreground(dim).Render(" · Send message to resume") + left = statusMessage(msg, red, " · Send message to resume", width) } case "budget_paused": left = lipgloss.NewStyle().Foreground(amber).Render("Budget limit reached") + @@ -670,15 +669,54 @@ func (m Model) statusView(width int) string { if msg == "" { msg = "Agent failed" } - left = lipgloss.NewStyle().Foreground(red).Render(msg) + - lipgloss.NewStyle().Foreground(dim).Render(" · Send message to resume") + left = statusMessage(msg, red, " · Send message to resume", width) } } if m.errorText != "" { - left = lipgloss.NewStyle().Foreground(red).Render(m.errorText) + left = statusMessage(m.errorText, red, "", width-lipgloss.Width(right)) } - gap := max(1, width-lipgloss.Width(left)-lipgloss.Width(right)) - return " " + left + strings.Repeat(" ", max(1, gap-1)) + right + return composeStatusRow(left, right, width) +} + +// composeStatusRow lays the status text and the corner hint on one row exactly +// width columns wide. A wider row would widen the whole chat column, because +// JoinHorizontal pads every row of a block to its widest, which pushes the +// sidebar off screen and wraps the frame. +func composeStatusRow(left, right string, width int) string { + if width <= 0 { + return "" + } + const leading = 1 // the row is indented one column, like the panels above it + // A terminal can be narrower than the hint itself. Drop the hint rather than + // keep it at the cost of the status, which is the part carrying information; + // ctrl-q works whether or not the row has room to say so. + if lipgloss.Width(right) > 0 && width < lipgloss.Width(right)+leading+2 { + right = "" + } + separator := 0 + if lipgloss.Width(right) > 0 { + separator = 1 + } + left = truncate(left, max(0, width-leading-lipgloss.Width(right)-separator)) + padding := max(0, width-leading-lipgloss.Width(left)-lipgloss.Width(right)) + return " " + left + strings.Repeat(" ", padding) + right +} + +// statusMessage fits a message and its trailing hint on the one status row. A +// model or backend error can be a wrapped exception several lines long, so it is +// flattened to a single line and clipped, leaving the hint readable. +func statusMessage(message string, color lipgloss.Color, hint string, width int) string { + styledHint := lipgloss.NewStyle().Foreground(dim).Render(hint) + room := max(1, width-2-lipgloss.Width(styledHint)) + flat := truncate(flattenStatus(message), room) + return lipgloss.NewStyle().Foreground(color).Render(flat) + styledHint +} + +// flattenStatus turns a multi-line message into one line, collapsing the runs of +// whitespace that joining its lines leaves behind. +func flattenStatus(message string) string { + message = strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ", "\t", " ").Replace(message) + return strings.Join(strings.Fields(message), " ") } func (m Model) sweepView() string { From 657aa5cbe687485135d1049450e36f296edb106d Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:34:52 -0700 Subject: [PATCH 35/57] feat(reporting): record transitive dependency chain on SCA findings (#971) Co-authored-by: Ahmed Allam --- .../interface/tui/internal/app/vuln_report.go | 2 + .../tui/internal/app/vulnerabilities.go | 2 + strix/report/sarif.py | 4 ++ strix/report/writer.py | 2 + .../skills/custom/dependency_cve_scanning.md | 36 ++++++++++- strix/tools/reporting/tool.py | 34 ++++++++++ tests/test_reporting_fields.py | 63 +++++++++++++++++++ 7 files changed, 141 insertions(+), 2 deletions(-) diff --git a/strix/interface/tui/internal/app/vuln_report.go b/strix/interface/tui/internal/app/vuln_report.go index b7749805..2f460926 100644 --- a/strix/interface/tui/internal/app/vuln_report.go +++ b/strix/interface/tui/internal/app/vuln_report.go @@ -74,6 +74,8 @@ func vulnerabilityMarkdownReport(v map[string]any) string { field("Ecosystem", render.StringValue(dep["package_ecosystem"])) field("Installed Version", render.StringValue(dep["installed_version"])) field("Fixed Version", render.StringValue(dep["fixed_version"])) + field("Introduced By", render.StringValue(dep["introduced_by"])) + field("Dependency Chain", render.StringValue(dep["dependency_path"])) } field("Endpoint", render.StringValue(v["endpoint"])) field("Method", render.StringValue(v["method"])) diff --git a/strix/interface/tui/internal/app/vulnerabilities.go b/strix/interface/tui/internal/app/vulnerabilities.go index 12b057d5..0d4f23b4 100644 --- a/strix/interface/tui/internal/app/vulnerabilities.go +++ b/strix/interface/tui/internal/app/vulnerabilities.go @@ -298,6 +298,8 @@ func vulnerabilityBody(v map[string]any) string { field("Ecosystem", render.StringValue(dep["package_ecosystem"])) field("Installed Version", render.StringValue(dep["installed_version"])) field("Fixed Version", render.StringValue(dep["fixed_version"])) + field("Introduced By", render.StringValue(dep["introduced_by"])) + field("Dependency Chain", render.StringValue(dep["dependency_path"])) } field("Endpoint", render.StringValue(v["endpoint"])) field("Method", render.StringValue(v["method"])) diff --git a/strix/report/sarif.py b/strix/report/sarif.py index 6d5db177..fc6e05db 100644 --- a/strix/report/sarif.py +++ b/strix/report/sarif.py @@ -531,6 +531,10 @@ def _result_properties( if value not in (None, ""): strix[key] = value + dependency_metadata = report.get("dependency_metadata") + if isinstance(dependency_metadata, dict) and dependency_metadata: + strix["dependency_metadata"] = dependency_metadata + # SARIF is written for external upload (code-scanning / ASPM), so it must # NOT carry the weaponized exploit payload — that stays a local run # artifact (vulnerabilities.json / the finding MD). We surface the PoC diff --git a/strix/report/writer.py b/strix/report/writer.py index 1b0a8a1d..ec592f14 100644 --- a/strix/report/writer.py +++ b/strix/report/writer.py @@ -205,6 +205,8 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL ("Ecosystem", dep_meta.get("package_ecosystem")), ("Installed Version", dep_meta.get("installed_version")), ("Fixed Version", dep_meta.get("fixed_version")), + ("Introduced By", dep_meta.get("introduced_by")), + ("Dependency Chain", dep_meta.get("dependency_path")), ("Endpoint", report.get("endpoint")), ("Method", report.get("method")), ("CVE", report.get("cve")), diff --git a/strix/skills/custom/dependency_cve_scanning.md b/strix/skills/custom/dependency_cve_scanning.md index 33ecf8c3..448af0e8 100644 --- a/strix/skills/custom/dependency_cve_scanning.md +++ b/strix/skills/custom/dependency_cve_scanning.md @@ -39,9 +39,11 @@ trivy version --format json 2>/dev/null | tee "$ART/trivy-version.json" # sandbox with egress gets the freshest CVEs; if the update fails, fall back to the # cached DB instead of failing the scan. --offline-scan keeps per-package advisory # lookups offline. -trivy fs --scanners vuln --timeout 30m --offline-scan \ +# --list-all-pkgs includes the package graph (Relationship + DependsOn) needed +# to attribute transitive CVEs to the direct dependency that introduces them. +trivy fs --scanners vuln --timeout 30m --offline-scan --list-all-pkgs \ --format json --output "$ART/trivy-sca.json" . \ - || trivy fs --scanners vuln --timeout 30m --offline-scan --skip-db-update \ + || trivy fs --scanners vuln --timeout 30m --offline-scan --skip-db-update --list-all-pkgs \ --format json --output "$ART/trivy-sca.json" . \ || true ``` @@ -78,6 +80,36 @@ For each entry under `.Results[].Vulnerabilities[]` in `trivy-sca.json`, collect Deduplicate by `(CVE, PkgName, InstalledVersion)`. File one `create_dependency_report` per CVE — do not batch multiple CVEs into one report. +### Attribute transitive CVEs to the direct dependency + +With `--list-all-pkgs`, each `.Results[].Packages[]` entry carries `ID` +(`name@version`), `Relationship` (`direct` / `indirect`) and `DependsOn` (the +`ID`s it resolves to). For every vulnerable package that is **indirect**, walk +the `DependsOn` graph backwards to find the `direct` package(s) whose closure +contains it, then pass to `create_dependency_report`: + +- `introduced_by` — the direct dependency as `name@version` (e.g. + `express@4.18.1`). If several direct dependencies pull it in, pick the + primary one and name the rest in `technical_analysis`. +- `dependency_path` — the shortest resolution chain from that direct + dependency to the vulnerable package, joined with ` > ` (e.g. + `express@4.18.1 > body-parser@1.20.0 > qs@6.10.2`). +- Omit both when the vulnerable package is itself a direct dependency. + +If the ecosystem's lockfile gives trivy no graph (`DependsOn` absent), derive +the chain from the package manager instead (`npm ls `, `pnpm why `, +`yarn why `, `pipdeptree --reverse -p `, `go mod graph`, +`mvn dependency:tree`, ...) — and if that also fails, leave the fields out +rather than guessing. + +For transitive findings, `remediation_steps` must be actionable at the +**direct-dependency level**: upgrading the vulnerable package directly is +usually impossible from the app's own manifest. Say which direct dependency to +bump (a version whose closure resolves the fixed version), or how to force the +resolution (npm `overrides` / yarn `resolutions` / pnpm `pnpm.overrides` / +Maven `dependencyManagement` / Gradle resolution strategy / `go mod edit`), +not just "upgrade to ". + ### Reachability is a confidence modifier, not a gate Do NOT suppress or downgrade a known CVE just because you could not prove the diff --git a/strix/tools/reporting/tool.py b/strix/tools/reporting/tool.py index 598def57..a1425315 100644 --- a/strix/tools/reporting/tool.py +++ b/strix/tools/reporting/tool.py @@ -725,6 +725,8 @@ def _build_dependency_metadata( installed_version: str, package_ecosystem: str | None, fixed_version: str | None, + introduced_by: str | None, + dependency_path: str | None, ) -> dict[str, str]: metadata = { "package_name": package_name.strip(), @@ -734,6 +736,10 @@ def _build_dependency_metadata( metadata["package_ecosystem"] = package_ecosystem.strip() if fixed_version and fixed_version.strip(): metadata["fixed_version"] = fixed_version.strip() + if introduced_by and introduced_by.strip(): + metadata["introduced_by"] = introduced_by.strip() + if dependency_path and dependency_path.strip(): + metadata["dependency_path"] = dependency_path.strip() return metadata @@ -743,6 +749,8 @@ def _build_dependency_evidence( package_name: str, installed_version: str, fixed_version: str | None, + introduced_by: str | None, + dependency_path: str | None, ) -> str: evidence = ( f"**Advisory evidence:** `{cve}` applies to `{package_name}` " @@ -750,6 +758,13 @@ def _build_dependency_evidence( ) if fixed_version and fixed_version.strip(): evidence += f" The advisory is fixed in `{fixed_version.strip()}`." + if introduced_by and introduced_by.strip(): + evidence += ( + f"\n\n**Transitive dependency:** introduced by the direct " + f"dependency `{introduced_by.strip()}`." + ) + if dependency_path and dependency_path.strip(): + evidence += f"\n\n**Dependency chain:** `{dependency_path.strip()}`" return evidence @@ -770,6 +785,8 @@ async def _do_create_dependency( # noqa: PLR0912 advisory_cvss: float | None, technical_analysis: str | None, fix_effort: str, + introduced_by: str | None = None, + dependency_path: str | None = None, agent_id: str | None = None, agent_name: str | None = None, ) -> dict[str, Any]: @@ -824,12 +841,16 @@ async def _do_create_dependency( # noqa: PLR0912 installed_version=installed_version, package_ecosystem=package_ecosystem, fixed_version=fixed_version, + introduced_by=introduced_by, + dependency_path=dependency_path, ) evidence = _build_dependency_evidence( cve=parsed_cve, package_name=package_name.strip(), installed_version=installed_version.strip(), fixed_version=fixed_version, + introduced_by=introduced_by, + dependency_path=dependency_path, ) try: @@ -926,6 +947,8 @@ async def create_dependency_report( cwe: str | None = None, technical_analysis: str | None = None, fix_effort: str = "low", + introduced_by: str | None = None, + dependency_path: str | None = None, ) -> str: """File a known-CVE dependency (SCA) finding — one report per CVE x package. @@ -978,6 +1001,15 @@ async def create_dependency_report( technical_analysis: Optional deeper mechanism/root-cause detail. fix_effort: One of ``trivial`` / ``low`` / ``medium`` / ``high`` (dependency upgrades are usually ``trivial``/``low``). + introduced_by: For a **transitive** dependency, the direct + dependency (from the project's own manifest) that pulls the + vulnerable package in, as ``name@version`` (e.g. + ``express@4.18.1``). Omit when the vulnerable package is + itself a direct dependency. + dependency_path: The resolution chain from the direct dependency + to the vulnerable package, joined with `` > `` (e.g. + ``express@4.18.1 > body-parser@1.20.0 > qs@6.10.2``). Omit + for direct dependencies. """ agent_id, agent_name = _caller_identity(ctx) @@ -997,6 +1029,8 @@ async def create_dependency_report( advisory_cvss=advisory_cvss, technical_analysis=technical_analysis, fix_effort=fix_effort, + introduced_by=introduced_by, + dependency_path=dependency_path, agent_id=agent_id, agent_name=agent_name, ) diff --git a/tests/test_reporting_fields.py b/tests/test_reporting_fields.py index 79fa4e9a..7bef970b 100644 --- a/tests/test_reporting_fields.py +++ b/tests/test_reporting_fields.py @@ -164,6 +164,69 @@ async def test_dependency_report_sets_class_and_metadata(report_state: ReportSta } +async def test_dependency_report_records_transitive_chain(report_state: ReportState) -> None: + result = await _do_create_dependency( + title="CVE-2022-24999 in qs 6.10.2", + description="Prototype pollution in qs parsing.", + target="repo/package.json", + cve="CVE-2022-24999", + package_name="qs", + installed_version="6.10.2", + impact="Denial of service via crafted query strings.", + remediation_steps="Upgrade express to 4.18.2, which resolves qs 6.11.0.", + assumptions="qs parses all incoming query strings by default.", + package_ecosystem="npm", + fixed_version="6.10.3", + cwe="CWE-1321", + advisory_cvss=7.5, + technical_analysis=None, + fix_effort="trivial", + introduced_by="express@4.18.1", + dependency_path="express@4.18.1 > body-parser@1.20.0 > qs@6.10.2", + ) + assert result["success"] is True + report = report_state.vulnerability_reports[0] + assert report["dependency_metadata"]["introduced_by"] == "express@4.18.1" + assert ( + report["dependency_metadata"]["dependency_path"] + == "express@4.18.1 > body-parser@1.20.0 > qs@6.10.2" + ) + assert ( + "**Transitive dependency:** introduced by the direct dependency `express@4.18.1`." + in report["evidence"] + ) + assert ( + "**Dependency chain:** `express@4.18.1 > body-parser@1.20.0 > qs@6.10.2`" + in report["evidence"] + ) + + +async def test_dependency_report_omits_blank_chain_fields(report_state: ReportState) -> None: + result = await _do_create_dependency( + title="CVE-2024-0001 in sample 1.0.0", + description="Published advisory affects the pinned version.", + target="repo/package.json", + cve="CVE-2024-0001", + package_name="sample", + installed_version="1.0.0", + impact="Impact.", + remediation_steps="Upgrade.", + assumptions="Assumptions.", + package_ecosystem="npm", + fixed_version=None, + cwe=None, + advisory_cvss=5.0, + technical_analysis=None, + fix_effort="trivial", + introduced_by=" ", + dependency_path=None, + ) + assert result["success"] is True + report = report_state.vulnerability_reports[0] + assert "introduced_by" not in report["dependency_metadata"] + assert "dependency_path" not in report["dependency_metadata"] + + async def test_dependency_report_with_zero_cvss_remains_low_severity( report_state: ReportState, ) -> None: From 68ea6fca653153677979e55fda6e4d06d281d31b Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:06:46 +0300 Subject: [PATCH 36/57] fix(llm): keep tool-call ids unique so a recycled id can't erase history (#976) Co-authored-by: Ahmed Allam --- pyproject.toml | 1 + strix/config/models.py | 113 ++++++++++++++- strix/config/tool_call_ids.py | 117 +++++++++++++++ tests/test_disable_streaming.py | 10 +- tests/test_tool_call_ids.py | 249 ++++++++++++++++++++++++++++++++ 5 files changed, 482 insertions(+), 8 deletions(-) create mode 100644 strix/config/tool_call_ids.py create mode 100644 tests/test_tool_call_ids.py diff --git a/pyproject.toml b/pyproject.toml index 38d5154c..d264dcca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -236,6 +236,7 @@ ignore = [ "strix/interface/auth_cli.py" = ["N802"] "tests/test_codex_streaming.py" = ["N802"] "tests/test_disable_streaming.py" = ["N802"] +"tests/test_tool_call_ids.py" = ["N802"] "tests/test_unknown_tool_recovery.py" = ["N802"] "tests/test_report_pdf.py" = ["S105", "S106"] # Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a diff --git a/strix/config/models.py b/strix/config/models.py index a3bb481a..8685a2fb 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -6,7 +6,7 @@ import contextlib import inspect import os import time -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from agents import ( set_default_openai_api, @@ -24,12 +24,18 @@ from agents.retry import ( RetryPolicyContext, retry_policies, ) -from openai.types.responses import Response, ResponseCompletedEvent +from openai.types.responses import ( + Response, + ResponseCompletedEvent, + ResponseOutputItemAddedEvent, + ResponseOutputItemDoneEvent, +) from openai.types.responses.response_usage import ResponseUsage from openai.types.shared import Reasoning from strix.config import codex from strix.config.loader import load_settings +from strix.config.tool_call_ids import TurnCallIdRewriter, dedupe_input if TYPE_CHECKING: @@ -229,6 +235,105 @@ class _NonStreamingModel(Model): yield _completed_stream_event(response, getattr(self._inner, "model", None)) +class _UniqueToolCallIdModel(Model): + """Keep tool-call ids unique so a recycled id can't invalidate the history. + + Providers that number tool calls per turn (``exec_command:0``, ...) restart + the counter each turn, so the same id eventually appears twice in one + conversation and strict providers reject every subsequent request. Ids that + collide with the history are rewritten before the turn is recorded, and + already-corrupted histories are repaired on the way out. + """ + + def __init__(self, inner: Model) -> None: + self._inner = inner + + async def close(self) -> None: + await self._inner.close() + + def get_retry_advice(self, request: ModelRetryAdviceRequest) -> ModelRetryAdvice | None: + return self._inner.get_retry_advice(request) + + async def get_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], # noqa: A002 + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchemaBase | None, + handoffs: list[Handoff], + tracing: ModelTracing, + *, + previous_response_id: str | None, + conversation_id: str | None, + prompt: ResponsePromptParam | None, + ) -> ModelResponse: + sanitized = dedupe_input(input) + rewriter = TurnCallIdRewriter(sanitized) + response = await self._inner.get_response( + system_instructions, + cast("str | list[TResponseInputItem]", sanitized), + model_settings, + tools, + output_schema, + handoffs, + tracing, + previous_response_id=previous_response_id, + conversation_id=conversation_id, + prompt=prompt, + ) + response.output = rewriter.rewrite_items(list(response.output)) + return response + + async def stream_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], # noqa: A002 + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchemaBase | None, + handoffs: list[Handoff], + tracing: ModelTracing, + *, + previous_response_id: str | None, + conversation_id: str | None, + prompt: ResponsePromptParam | None, + ) -> AsyncIterator[TResponseStreamEvent]: + sanitized = dedupe_input(input) + rewriter = TurnCallIdRewriter(sanitized) + stream = self._inner.stream_response( + system_instructions, + cast("str | list[TResponseInputItem]", sanitized), + model_settings, + tools, + output_schema, + handoffs, + tracing, + previous_response_id=previous_response_id, + conversation_id=conversation_id, + prompt=prompt, + ) + async for event in stream: + yield _rewrite_event_call_ids(event, rewriter) + + +def _rewrite_event_call_ids( + event: TResponseStreamEvent, rewriter: TurnCallIdRewriter +) -> TResponseStreamEvent: + if isinstance(event, ResponseOutputItemAddedEvent | ResponseOutputItemDoneEvent): + rewritten = rewriter.rewrite_item(event.item) + if rewritten is not event.item: + return event.model_copy(update={"item": rewritten}) + return event + if isinstance(event, ResponseCompletedEvent): + output = rewriter.rewrite_items(list(event.response.output)) + if output != list(event.response.output): + return event.model_copy( + update={"response": event.response.model_copy(update={"output": output})} + ) + return event + + def _completed_stream_event( model_response: ModelResponse, model_name: object | None ) -> TResponseStreamEvent: @@ -305,8 +410,8 @@ class StrixProvider(MultiProvider): ) model = super().get_model(model_name) if llm.disable_streaming: - return _NonStreamingModel(model) - return model + model = _NonStreamingModel(model) + return _UniqueToolCallIdModel(model) DEFAULT_MODEL_RETRY = ModelRetrySettings( diff --git a/strix/config/tool_call_ids.py b/strix/config/tool_call_ids.py new file mode 100644 index 00000000..31f78850 --- /dev/null +++ b/strix/config/tool_call_ids.py @@ -0,0 +1,117 @@ +"""Keep tool-call ids unique within a conversation. + +Some providers return per-turn tool-call ids (``exec_command:0``, +``exec_command:1``, ...) whose counter restarts on every turn. Once the same +id appears twice in one conversation, the request payload has two assistant +tool calls sharing an id and strict providers reject the whole turn, which +permanently kills the agent because the malformed history is replayed on +every retry. Rewriting duplicates to fresh unique ids keeps the history +valid for any provider. +""" + +from __future__ import annotations + +from collections import defaultdict, deque +from typing import Any +from uuid import uuid4 + +from openai.types.responses import ResponseFunctionToolCall + + +def new_call_id() -> str: + return f"call_{uuid4().hex}" + + +def collect_call_ids(items: list[Any]) -> set[str]: + used: set[str] = set() + for item in items: + if isinstance(item, dict): + call_id = item.get("call_id") + if isinstance(call_id, str): + used.add(call_id) + elif isinstance(item, ResponseFunctionToolCall): + used.add(item.call_id) + return used + + +def dedupe_history_call_ids(items: list[Any]) -> tuple[list[Any], bool]: + """Rewrite duplicate call ids in a conversation history. + + Outputs are paired with their call by order, so parallel calls that share + an id keep answering the right call after the rewrite. + """ + used: set[str] = set() + pending: dict[str, deque[str]] = defaultdict(deque) + rebuilt: list[Any] = [] + changed = False + + for item in items: + if not isinstance(item, dict): + rebuilt.append(item) + continue + call_id = item.get("call_id") + if not isinstance(call_id, str): + rebuilt.append(item) + continue + + kind = item.get("type") + if kind == "function_call": + effective = call_id + if call_id in used: + effective = new_call_id() + item = {**item, "call_id": effective} # noqa: PLW2901 + changed = True + used.add(effective) + pending[call_id].append(effective) + elif kind == "function_call_output": + queue = pending.get(call_id) + if queue: + effective = queue.popleft() + if effective != call_id: + item = {**item, "call_id": effective} # noqa: PLW2901 + changed = True + rebuilt.append(item) + + return rebuilt, changed + + +def dedupe_input(model_input: str | list[Any]) -> str | list[Any]: + if isinstance(model_input, str): + return model_input + rebuilt, changed = dedupe_history_call_ids(model_input) + return rebuilt if changed else model_input + + +class TurnCallIdRewriter: + """Rewrite a single turn's tool-call ids that collide with the history. + + A turn's items surface several times (streamed item events, then the + completed response), so the same original id must always map to the same + replacement within the turn. + """ + + def __init__(self, model_input: str | list[Any]) -> None: + self._used = set() if isinstance(model_input, str) else collect_call_ids(model_input) + self._remap: dict[str, str] = {} + self._settled: set[str] = set() + + def rewrite_item(self, item: Any) -> Any: + if not isinstance(item, ResponseFunctionToolCall): + return item + original = item.call_id + if original in self._settled: + return item + replacement = self._remap.get(original) + if replacement is None: + if original not in self._used: + self._used.add(original) + self._settled.add(original) + return item + replacement = new_call_id() + self._remap[original] = replacement + self._used.add(replacement) + self._settled.add(replacement) + return item.model_copy(update={"call_id": replacement}) + + def rewrite_items(self, items: list[Any]) -> list[Any]: + return [self.rewrite_item(item) for item in items] diff --git a/tests/test_disable_streaming.py b/tests/test_disable_streaming.py index 1d667e61..d8438a54 100644 --- a/tests/test_disable_streaming.py +++ b/tests/test_disable_streaming.py @@ -31,7 +31,7 @@ from openai.types.responses import ( from strix.config import codex, loader from strix.config.loader import load_settings -from strix.config.models import StrixProvider, _NonStreamingModel +from strix.config.models import StrixProvider, _NonStreamingModel, _UniqueToolCallIdModel if TYPE_CHECKING: @@ -299,10 +299,11 @@ def test_get_model_wraps_when_disabled( load_settings() model = StrixProvider().get_model("openai/gpt-4o-mini") - assert isinstance(model, _NonStreamingModel) + assert isinstance(model, _UniqueToolCallIdModel) + assert isinstance(model._inner, _NonStreamingModel) -def test_get_model_unwrapped_by_default( +def test_get_model_keeps_streaming_by_default( monkeypatch: pytest.MonkeyPatch, _reset_settings: None ) -> None: inner = _DummyModel() @@ -310,7 +311,8 @@ def test_get_model_unwrapped_by_default( load_settings() model = StrixProvider().get_model("openai/gpt-4o-mini") - assert model is inner + assert isinstance(model, _UniqueToolCallIdModel) + assert model._inner is inner def test_get_model_does_not_wrap_subscription_model( diff --git a/tests/test_tool_call_ids.py b/tests/test_tool_call_ids.py new file mode 100644 index 00000000..a4033f4b --- /dev/null +++ b/tests/test_tool_call_ids.py @@ -0,0 +1,249 @@ +"""Tests for tool-call id uniqueness. + +Providers that number tool calls per turn (``exec_command:0``, ``:1``, ...) +restart the counter on every turn, so the same id eventually appears twice in +one conversation. Strict providers then reject the whole request, and because +the history is replayed on every retry the agent can never recover. A gateway +that validates id uniqueness the way those providers do proves both the +failure and the fix. +""" + +from __future__ import annotations + +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import TYPE_CHECKING, Any + +import pytest +from agents import Agent, Runner, function_tool +from agents.models.interface import Model, ModelProvider +from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel +from agents.run import RunConfig +from openai import AsyncOpenAI +from openai.types.responses import ResponseFunctionToolCall + +from strix.config.models import _NonStreamingModel, _UniqueToolCallIdModel +from strix.config.tool_call_ids import TurnCallIdRewriter, dedupe_history_call_ids + + +if TYPE_CHECKING: + from collections.abc import Iterator + + +def _tool_call_completion(call_id: str, n: int = 1) -> dict[str, Any]: + return { + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 0, + "model": "gw-model", + "choices": [ + { + "index": 0, + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": call_id, + "type": "function", + "function": {"name": "do_thing", "arguments": json.dumps({"n": n})}, + } + ], + }, + } + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7}, + } + + +def _text_completion(text: str) -> dict[str, Any]: + return { + "id": "chatcmpl-2", + "object": "chat.completion", + "created": 0, + "model": "gw-model", + "choices": [ + {"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": text}} + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}, + } + + +_REQUESTS: list[list[dict[str, Any]]] = [] + + +def _assistant_call_ids(messages: list[dict[str, Any]]) -> list[str]: + return [str(call.get("id")) for message in messages for call in message.get("tool_calls") or []] + + +def _tool_results(messages: list[dict[str, Any]]) -> list[str]: + return [str(m.get("content")) for m in messages if m.get("role") == "tool"] + + +class _StrictHandler(BaseHTTPRequestHandler): + """Gateway that rejects a history reusing a tool-call id, like strict providers do.""" + + def log_message(self, *args: Any) -> None: + pass + + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length) or b"{}") + messages = body.get("messages", []) + _REQUESTS.append(messages) + call_ids = _assistant_call_ids(messages) + + if len(call_ids) != len(set(call_ids)): + self._respond( + 400, + { + "error": { + "message": ( + "tool messages need a resolvable tool name: carry `tool`/`name`, " + "or match a preceding assistant tool_call by order" + ) + } + }, + ) + return + + turn = len(_REQUESTS) + if turn <= 2: + # The provider restarts its per-turn counter, so both turns say ":0". + self._respond(200, _tool_call_completion("exec_command:0", n=turn)) + else: + self._respond(200, _text_completion("all done")) + + def _respond(self, status: int, payload: dict[str, Any]) -> None: + encoded = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + +@pytest.fixture +def strict_gateway() -> Iterator[str]: + _REQUESTS.clear() + server = HTTPServer(("127.0.0.1", 0), _StrictHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_address[1]}/v1" + finally: + server.shutdown() + server.server_close() + + +def _model(base_url: str) -> Model: + # The gateway answers plain JSON, so the run loop's streamed turns are + # served non-streamed; the ids on the wire are the same either way. + client = AsyncOpenAI(api_key="tok", base_url=base_url, max_retries=0) + return _NonStreamingModel(OpenAIChatCompletionsModel(model="gw-model", openai_client=client)) + + +async def _run_agent(base_url: str, *, wrap: bool) -> Any: + @function_tool + def do_thing(n: int) -> str: + return f"did {n}" + + class _Provider(ModelProvider): + def get_model(self, model_name: str | None) -> Model: # noqa: ARG002 + model = _model(base_url) + return _UniqueToolCallIdModel(model) if wrap else model + + agent = Agent(name="t", instructions="use the tool", tools=[do_thing], model="gw-model") + result = Runner.run_streamed( + agent, input="please", run_config=RunConfig(model_provider=_Provider()) + ) + async for _ in result.stream_events(): + pass + return result + + +@pytest.mark.asyncio +async def test_recycled_call_id_erases_a_turn_without_the_wrapper(strict_gateway: str) -> None: + # Repro: two turns run a tool and both are labelled ``exec_command:0``, so + # the colliding call and its result are dropped as duplicates. The agent + # ends the run having silently lost a turn of its own work — and a provider + # that does not drop them instead rejects the malformed history outright. + result = await _run_agent(strict_gateway, wrap=False) + + assert result.final_output == "all done" + assert _assistant_call_ids(_REQUESTS[-1]) == ["exec_command:0"] + assert _tool_results(_REQUESTS[-1]) == ["did 2"] + + +@pytest.mark.asyncio +async def test_recycled_call_id_is_rewritten_so_no_turn_is_lost(strict_gateway: str) -> None: + result = await _run_agent(strict_gateway, wrap=True) + + assert result.final_output == "all done" + call_ids = _assistant_call_ids(_REQUESTS[-1]) + assert len(call_ids) == len(set(call_ids)) == 2 + assert call_ids[0] == "exec_command:0" + assert call_ids[1].startswith("call_") + assert _tool_results(_REQUESTS[-1]) == ["did 1", "did 2"] + + +def test_history_dedupe_keeps_outputs_paired_with_their_call() -> None: + items = [ + {"type": "function_call", "call_id": "exec_command:0", "name": "a", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "exec_command:0", "output": "first"}, + {"type": "function_call", "call_id": "exec_command:0", "name": "b", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "exec_command:0", "output": "second"}, + ] + + rebuilt, changed = dedupe_history_call_ids(items) + + assert changed + ids = [item["call_id"] for item in rebuilt] + assert ids[0] == ids[1] == "exec_command:0" + assert ids[2] == ids[3] != "exec_command:0" + assert rebuilt[3]["output"] == "second" + + +def test_history_dedupe_pairs_parallel_calls_by_order() -> None: + items = [ + {"type": "function_call", "call_id": "dup", "name": "a", "arguments": "{}"}, + {"type": "function_call", "call_id": "dup", "name": "b", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "dup", "output": "for-a"}, + {"type": "function_call_output", "call_id": "dup", "output": "for-b"}, + ] + + rebuilt, changed = dedupe_history_call_ids(items) + + assert changed + assert rebuilt[0]["call_id"] == rebuilt[2]["call_id"] == "dup" + assert rebuilt[1]["call_id"] == rebuilt[3]["call_id"] + assert rebuilt[1]["call_id"] != "dup" + + +def test_history_dedupe_leaves_unique_ids_alone() -> None: + items = [ + {"type": "function_call", "call_id": "call_a", "name": "a", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "call_a", "output": "x"}, + {"type": "function_call", "call_id": "call_b", "name": "b", "arguments": "{}"}, + ] + + rebuilt, changed = dedupe_history_call_ids(items) + + assert not changed + assert rebuilt == items + + +def test_turn_rewriter_is_stable_across_repeated_sightings() -> None: + history = [{"type": "function_call", "call_id": "exec_command:0", "name": "a"}] + rewriter = TurnCallIdRewriter(history) + call = ResponseFunctionToolCall( + call_id="exec_command:0", name="a", arguments="{}", type="function_call" + ) + + first = rewriter.rewrite_item(call) + second = rewriter.rewrite_item(first) + + assert first.call_id != "exec_command:0" + assert second.call_id == first.call_id From 8bd6c8e87a35fa9d211587756ffcb8c32626dc9d Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:06:59 +0300 Subject: [PATCH 37/57] fix(llm): cap the tool calls one assistant response may queue (#977) * fix(llm): cap the tool calls one assistant response may queue * fix(llm): cap the subscription backend's responses too --------- Co-authored-by: Ahmed Allam --- pyproject.toml | 1 + strix/config/models.py | 71 ++++++++---- strix/config/settings.py | 5 + strix/config/tool_call_limits.py | 46 ++++++++ tests/test_disable_streaming.py | 14 ++- tests/test_tool_call_ids.py | 4 +- tests/test_tool_call_limits.py | 189 +++++++++++++++++++++++++++++++ 7 files changed, 302 insertions(+), 28 deletions(-) create mode 100644 strix/config/tool_call_limits.py create mode 100644 tests/test_tool_call_limits.py diff --git a/pyproject.toml b/pyproject.toml index d264dcca..a35ea357 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -237,6 +237,7 @@ ignore = [ "tests/test_codex_streaming.py" = ["N802"] "tests/test_disable_streaming.py" = ["N802"] "tests/test_tool_call_ids.py" = ["N802"] +"tests/test_tool_call_limits.py" = ["N802", "SLF001"] "tests/test_unknown_tool_recovery.py" = ["N802"] "tests/test_report_pdf.py" = ["S105", "S106"] # Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a diff --git a/strix/config/models.py b/strix/config/models.py index 8685a2fb..f14abfcf 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -4,6 +4,7 @@ from __future__ import annotations import contextlib import inspect +import logging import os import time from typing import TYPE_CHECKING, Any, cast @@ -36,6 +37,7 @@ from openai.types.shared import Reasoning from strix.config import codex from strix.config.loader import load_settings from strix.config.tool_call_ids import TurnCallIdRewriter, dedupe_input +from strix.config.tool_call_limits import TurnToolCallLimiter if TYPE_CHECKING: @@ -54,6 +56,9 @@ if TYPE_CHECKING: from strix.config.settings import LlmSettings, ReasoningEffort, Settings +logger = logging.getLogger(__name__) + + def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None: """Per-request model timeout; a plain float so ``ModelSettings.to_json_dict()`` stays serializable.""" # noqa: E501 if not timeout_s or timeout_s <= 0: @@ -235,18 +240,34 @@ class _NonStreamingModel(Model): yield _completed_stream_event(response, getattr(self._inner, "model", None)) -class _UniqueToolCallIdModel(Model): - """Keep tool-call ids unique so a recycled id can't invalidate the history. +class _TurnGuardModel(Model): + """Keep one turn from corrupting the conversation or running away. - Providers that number tool calls per turn (``exec_command:0``, ...) restart - the counter each turn, so the same id eventually appears twice in one - conversation and strict providers reject every subsequent request. Ids that - collide with the history are rewritten before the turn is recorded, and - already-corrupted histories are repaired on the way out. + Tool-call ids: providers that number calls per turn (``exec_command:0``, + ...) restart the counter each turn, so the same id eventually appears twice + in one conversation and strict providers reject every subsequent request. + Ids that collide with the history are rewritten before the turn is + recorded, and already-corrupted histories are repaired on the way out. + + Tool-call volume: a degenerate response can queue hundreds of calls that + the run loop then honours one by one. Only the first + ``LLM_MAX_TOOL_CALLS_PER_TURN`` calls of a response are kept. """ - def __init__(self, inner: Model) -> None: + def __init__(self, inner: Model, *, max_tool_calls_per_turn: int = 0) -> None: self._inner = inner + self._max_tool_calls_per_turn = max_tool_calls_per_turn + + def _limiter(self) -> TurnToolCallLimiter: + return TurnToolCallLimiter(self._max_tool_calls_per_turn) + + def _log_dropped(self, limiter: TurnToolCallLimiter) -> None: + if limiter.dropped: + logger.warning( + "dropped %d tool call(s) past the per-response limit of %d", + limiter.dropped, + self._max_tool_calls_per_turn, + ) async def close(self) -> None: await self._inner.close() @@ -282,7 +303,9 @@ class _UniqueToolCallIdModel(Model): conversation_id=conversation_id, prompt=prompt, ) - response.output = rewriter.rewrite_items(list(response.output)) + limiter = self._limiter() + response.output = limiter.filter_items(rewriter.rewrite_items(list(response.output))) + self._log_dropped(limiter) return response async def stream_response( @@ -301,6 +324,7 @@ class _UniqueToolCallIdModel(Model): ) -> AsyncIterator[TResponseStreamEvent]: sanitized = dedupe_input(input) rewriter = TurnCallIdRewriter(sanitized) + limiter = self._limiter() stream = self._inner.stream_response( system_instructions, cast("str | list[TResponseInputItem]", sanitized), @@ -314,20 +338,26 @@ class _UniqueToolCallIdModel(Model): prompt=prompt, ) async for event in stream: - yield _rewrite_event_call_ids(event, rewriter) + guarded = _guard_event(event, rewriter, limiter) + if guarded is not None: + yield guarded + self._log_dropped(limiter) -def _rewrite_event_call_ids( - event: TResponseStreamEvent, rewriter: TurnCallIdRewriter -) -> TResponseStreamEvent: +def _guard_event( + event: TResponseStreamEvent, rewriter: TurnCallIdRewriter, limiter: TurnToolCallLimiter +) -> TResponseStreamEvent | None: if isinstance(event, ResponseOutputItemAddedEvent | ResponseOutputItemDoneEvent): rewritten = rewriter.rewrite_item(event.item) + if not limiter.allow(rewritten): + return None if rewritten is not event.item: return event.model_copy(update={"item": rewritten}) return event if isinstance(event, ResponseCompletedEvent): - output = rewriter.rewrite_items(list(event.response.output)) - if output != list(event.response.output): + original = list(event.response.output) + output = limiter.filter_items(rewriter.rewrite_items(original)) + if output != original: return event.model_copy( update={"response": event.response.model_copy(update={"output": output})} ) @@ -403,15 +433,16 @@ class StrixProvider(MultiProvider): # The ChatGPT subscription backend is always streamed; it has no # non-streaming mode to fall back to, so LLM_DISABLE_STREAMING # does not apply here. - return _CodexResponsesModel( + model: Model = _CodexResponsesModel( slug, codex.get_subscription_client(), reasoning_effort=llm.reasoning_effort, ) - model = super().get_model(model_name) - if llm.disable_streaming: - model = _NonStreamingModel(model) - return _UniqueToolCallIdModel(model) + else: + model = super().get_model(model_name) + if llm.disable_streaming: + model = _NonStreamingModel(model) + return _TurnGuardModel(model, max_tool_calls_per_turn=llm.max_tool_calls_per_turn) DEFAULT_MODEL_RETRY = ModelRetrySettings( diff --git a/strix/config/settings.py b/strix/config/settings.py index eda4ebce..a4a18b78 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -57,6 +57,11 @@ class LlmSettings(BaseSettings): alias="LLM_DISABLE_STREAMING", ) timeout: int = Field(default=300, alias="LLM_TIMEOUT") + max_tool_calls_per_turn: int = Field( + default=32, + ge=0, + alias="LLM_MAX_TOOL_CALLS_PER_TURN", + ) class DedupeSettings(BaseSettings): diff --git a/strix/config/tool_call_limits.py b/strix/config/tool_call_limits.py new file mode 100644 index 00000000..a014e8db --- /dev/null +++ b/strix/config/tool_call_limits.py @@ -0,0 +1,46 @@ +"""Bound how many tool calls one assistant response may queue. + +A degenerate generation can emit hundreds or thousands of tool calls in a +single response — typically a poll/wait loop the model writes out ahead of +time instead of issuing one call and yielding. The run loop honours all of +them, so the agent stops reacting to anything for hours. Keeping only the +first ``limit`` calls of a response bounds that blast radius; the model sees +their results on the next turn and can reconsider. +""" + +from __future__ import annotations + +from typing import Any + +from openai.types.responses import ResponseFunctionToolCall + + +class TurnToolCallLimiter: + """Decide, once per call, whether a turn's tool call is within the limit.""" + + def __init__(self, limit: int) -> None: + self._limit = limit + self._decisions: dict[str, bool] = {} + self._kept = 0 + self.dropped = 0 + + @property + def enabled(self) -> bool: + return self._limit > 0 + + def allow(self, item: Any) -> bool: + if not self.enabled or not isinstance(item, ResponseFunctionToolCall): + return True + decided = self._decisions.get(item.call_id) + if decided is not None: + return decided + allowed = self._kept < self._limit + if allowed: + self._kept += 1 + else: + self.dropped += 1 + self._decisions[item.call_id] = allowed + return allowed + + def filter_items(self, items: list[Any]) -> list[Any]: + return [item for item in items if self.allow(item)] diff --git a/tests/test_disable_streaming.py b/tests/test_disable_streaming.py index d8438a54..00e99bcc 100644 --- a/tests/test_disable_streaming.py +++ b/tests/test_disable_streaming.py @@ -31,7 +31,7 @@ from openai.types.responses import ( from strix.config import codex, loader from strix.config.loader import load_settings -from strix.config.models import StrixProvider, _NonStreamingModel, _UniqueToolCallIdModel +from strix.config.models import StrixProvider, _NonStreamingModel, _TurnGuardModel if TYPE_CHECKING: @@ -299,7 +299,7 @@ def test_get_model_wraps_when_disabled( load_settings() model = StrixProvider().get_model("openai/gpt-4o-mini") - assert isinstance(model, _UniqueToolCallIdModel) + assert isinstance(model, _TurnGuardModel) assert isinstance(model._inner, _NonStreamingModel) @@ -311,18 +311,20 @@ def test_get_model_keeps_streaming_by_default( load_settings() model = StrixProvider().get_model("openai/gpt-4o-mini") - assert isinstance(model, _UniqueToolCallIdModel) + assert isinstance(model, _TurnGuardModel) assert model._inner is inner -def test_get_model_does_not_wrap_subscription_model( +def test_get_model_guards_subscription_model_but_keeps_it_streaming( monkeypatch: pytest.MonkeyPatch, _reset_settings: None ) -> None: - # Subscription (ChatGPT) models are always streamed and must not be wrapped. + # Subscription (ChatGPT) models are always streamed, so LLM_DISABLE_STREAMING + # must not apply — but a runaway response needs capping there too. monkeypatch.setattr(codex, "subscription_model", lambda *_: "gpt-5.5") monkeypatch.setattr(codex, "get_subscription_client", lambda: AsyncOpenAI(api_key="x")) monkeypatch.setenv("LLM_DISABLE_STREAMING", "true") load_settings() model = StrixProvider().get_model("gpt-5.5") - assert not isinstance(model, _NonStreamingModel) + assert isinstance(model, _TurnGuardModel) + assert not isinstance(model._inner, _NonStreamingModel) diff --git a/tests/test_tool_call_ids.py b/tests/test_tool_call_ids.py index a4033f4b..bfb03598 100644 --- a/tests/test_tool_call_ids.py +++ b/tests/test_tool_call_ids.py @@ -23,7 +23,7 @@ from agents.run import RunConfig from openai import AsyncOpenAI from openai.types.responses import ResponseFunctionToolCall -from strix.config.models import _NonStreamingModel, _UniqueToolCallIdModel +from strix.config.models import _NonStreamingModel, _TurnGuardModel from strix.config.tool_call_ids import TurnCallIdRewriter, dedupe_history_call_ids @@ -153,7 +153,7 @@ async def _run_agent(base_url: str, *, wrap: bool) -> Any: class _Provider(ModelProvider): def get_model(self, model_name: str | None) -> Model: # noqa: ARG002 model = _model(base_url) - return _UniqueToolCallIdModel(model) if wrap else model + return _TurnGuardModel(model) if wrap else model agent = Agent(name="t", instructions="use the tool", tools=[do_thing], model="gw-model") result = Runner.run_streamed( diff --git a/tests/test_tool_call_limits.py b/tests/test_tool_call_limits.py new file mode 100644 index 00000000..2c34a213 --- /dev/null +++ b/tests/test_tool_call_limits.py @@ -0,0 +1,189 @@ +"""Tests for the per-response tool-call cap. + +A degenerate generation can emit hundreds of tool calls in one assistant +response — a wait/poll loop the model writes out ahead of time. The run loop +honours every one of them, so the agent stops reacting for hours. The cap +keeps the first N calls of a response and drops the tail. +""" + +from __future__ import annotations + +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import TYPE_CHECKING, Any + +import pytest +from agents import Agent, Runner, function_tool +from agents.models.interface import Model, ModelProvider +from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel +from agents.run import RunConfig +from openai import AsyncOpenAI + +from strix.config import loader +from strix.config.loader import load_settings +from strix.config.models import StrixProvider, _NonStreamingModel, _TurnGuardModel + + +if TYPE_CHECKING: + from collections.abc import Iterator + + +_RUNAWAY_CALLS = 200 +_CAP = 32 + + +def _runaway_completion() -> dict[str, Any]: + return { + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 0, + "model": "gw-model", + "choices": [ + { + "index": 0, + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": f"call_{i}", + "type": "function", + "function": {"name": "wait_for_message", "arguments": "{}"}, + } + for i in range(_RUNAWAY_CALLS) + ], + }, + } + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7}, + } + + +def _text_completion() -> dict[str, Any]: + return { + "id": "chatcmpl-2", + "object": "chat.completion", + "created": 0, + "model": "gw-model", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant", "content": "done"}, + } + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}, + } + + +_TURNS: list[int] = [] + + +class _RunawayHandler(BaseHTTPRequestHandler): + """First turn queues a huge poll loop; the next turn ends the run.""" + + def log_message(self, *args: Any) -> None: + pass + + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", 0)) + self.rfile.read(length) + _TURNS.append(1) + payload = _runaway_completion() if len(_TURNS) == 1 else _text_completion() + encoded = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + +@pytest.fixture +def runaway_gateway() -> Iterator[str]: + _TURNS.clear() + server = HTTPServer(("127.0.0.1", 0), _RunawayHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_address[1]}/v1" + finally: + server.shutdown() + server.server_close() + + +def _model(base_url: str) -> Model: + client = AsyncOpenAI(api_key="tok", base_url=base_url, max_retries=0) + return _NonStreamingModel(OpenAIChatCompletionsModel(model="gw-model", openai_client=client)) + + +async def _run_agent(base_url: str, *, cap: int) -> list[int]: + executed: list[int] = [] + + @function_tool + def wait_for_message() -> str: + executed.append(1) + return "nothing new" + + class _Provider(ModelProvider): + def get_model(self, model_name: str | None) -> Model: # noqa: ARG002 + return _TurnGuardModel(_model(base_url), max_tool_calls_per_turn=cap) + + agent = Agent(name="t", instructions="orchestrate", tools=[wait_for_message], model="gw-model") + result = Runner.run_streamed( + agent, input="go", run_config=RunConfig(model_provider=_Provider()) + ) + async for _ in result.stream_events(): + pass + assert result.final_output == "done" + return executed + + +@pytest.mark.asyncio +async def test_runaway_response_runs_every_queued_call_when_uncapped(runaway_gateway: str) -> None: + # Repro: one response queues 200 calls and the run loop honours all of them. + executed = await _run_agent(runaway_gateway, cap=0) + + assert len(executed) == _RUNAWAY_CALLS + + +@pytest.mark.asyncio +async def test_runaway_response_is_capped(runaway_gateway: str) -> None: + executed = await _run_agent(runaway_gateway, cap=_CAP) + + assert len(executed) == _CAP + + +@pytest.mark.asyncio +async def test_response_below_the_cap_is_untouched(runaway_gateway: str) -> None: + executed = await _run_agent(runaway_gateway, cap=_RUNAWAY_CALLS + 1) + + assert len(executed) == _RUNAWAY_CALLS + + +@pytest.fixture +def _reset_settings(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + for key in ("STRIX_LLM", "LLM_DISABLE_STREAMING", "LLM_MAX_TOOL_CALLS_PER_TURN"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setattr(loader, "_cached", None) + monkeypatch.setattr(loader, "_override", None) + yield + + +class _DummyModel(Model): + async def get_response(self, *args: Any, **kwargs: Any) -> Any: + raise NotImplementedError + + def stream_response(self, *args: Any, **kwargs: Any) -> Any: + raise NotImplementedError + + +def test_cap_is_configurable(monkeypatch: pytest.MonkeyPatch, _reset_settings: None) -> None: + monkeypatch.setattr("strix.config.models.MultiProvider.get_model", lambda *_: _DummyModel()) + monkeypatch.setenv("LLM_MAX_TOOL_CALLS_PER_TURN", "7") + load_settings() + + model = StrixProvider().get_model("openai/gpt-4o-mini") + assert isinstance(model, _TurnGuardModel) + assert model._max_tool_calls_per_turn == 7 From 6735a6f89e7c479141c2394ce21f0e17b39009fd Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:07:14 +0300 Subject: [PATCH 38/57] fix(llm): abandon a model stream that stops producing events (#978) Co-authored-by: Ahmed Allam --- pyproject.toml | 1 + strix/config/models.py | 57 +++++++++- strix/config/settings.py | 1 + tests/test_stream_idle_timeout.py | 173 ++++++++++++++++++++++++++++++ 4 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 tests/test_stream_idle_timeout.py diff --git a/pyproject.toml b/pyproject.toml index a35ea357..bcc91b03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -238,6 +238,7 @@ ignore = [ "tests/test_disable_streaming.py" = ["N802"] "tests/test_tool_call_ids.py" = ["N802"] "tests/test_tool_call_limits.py" = ["N802", "SLF001"] +"tests/test_stream_idle_timeout.py" = ["N802", "SLF001"] "tests/test_unknown_tool_recovery.py" = ["N802"] "tests/test_report_pdf.py" = ["S105", "S106"] # Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a diff --git a/strix/config/models.py b/strix/config/models.py index f14abfcf..e8544975 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -2,11 +2,13 @@ from __future__ import annotations +import asyncio import contextlib import inspect import logging import os import time +from collections.abc import AsyncGenerator from typing import TYPE_CHECKING, Any, cast from agents import ( @@ -252,11 +254,23 @@ class _TurnGuardModel(Model): Tool-call volume: a degenerate response can queue hundreds of calls that the run loop then honours one by one. Only the first ``LLM_MAX_TOOL_CALLS_PER_TURN`` calls of a response are kept. + + Stalled streams: a turn that emits a few tokens and then goes silent is + not covered by the request timeout, which resets on any byte (keepalives + included). ``LLM_STREAM_IDLE_TIMEOUT`` bounds the gap between events so the + turn fails instead of hanging, and the existing retry path replays it. """ - def __init__(self, inner: Model, *, max_tool_calls_per_turn: int = 0) -> None: + def __init__( + self, + inner: Model, + *, + max_tool_calls_per_turn: int = 0, + stream_idle_timeout: float = 0.0, + ) -> None: self._inner = inner self._max_tool_calls_per_turn = max_tool_calls_per_turn + self._stream_idle_timeout = stream_idle_timeout def _limiter(self) -> TurnToolCallLimiter: return TurnToolCallLimiter(self._max_tool_calls_per_turn) @@ -337,13 +351,41 @@ class _TurnGuardModel(Model): conversation_id=conversation_id, prompt=prompt, ) - async for event in stream: + async for event in _with_idle_timeout(stream, self._stream_idle_timeout): guarded = _guard_event(event, rewriter, limiter) if guarded is not None: yield guarded self._log_dropped(limiter) +async def _aclose(stream: AsyncIterator[TResponseStreamEvent]) -> None: + if isinstance(stream, AsyncGenerator): + with contextlib.suppress(Exception): + await stream.aclose() + + +async def _with_idle_timeout( + stream: AsyncIterator[TResponseStreamEvent], timeout: float +) -> AsyncIterator[TResponseStreamEvent]: + if timeout <= 0: + async for event in stream: + yield event + return + + iterator = stream.__aiter__() + while True: + try: + event = await asyncio.wait_for(iterator.__anext__(), timeout) + except StopAsyncIteration: + return + except TimeoutError: + await _aclose(stream) + message = f"model stream produced no event for {timeout:.0f}s" + logger.warning("%s; abandoning the turn", message) + raise TimeoutError(message) from None + yield event + + def _guard_event( event: TResponseStreamEvent, rewriter: TurnCallIdRewriter, limiter: TurnToolCallLimiter ) -> TResponseStreamEvent | None: @@ -429,6 +471,7 @@ class StrixProvider(MultiProvider): def get_model(self, model_name: str | None) -> Model: llm = load_settings().llm slug = codex.subscription_model(model_name) + idle_timeout = float(llm.stream_idle_timeout) if slug: # The ChatGPT subscription backend is always streamed; it has no # non-streaming mode to fall back to, so LLM_DISABLE_STREAMING @@ -442,7 +485,15 @@ class StrixProvider(MultiProvider): model = super().get_model(model_name) if llm.disable_streaming: model = _NonStreamingModel(model) - return _TurnGuardModel(model, max_tool_calls_per_turn=llm.max_tool_calls_per_turn) + # The wrapper emits its single event only once the whole request + # is done, so an idle gap is meaningless here; the request + # timeout bounds it instead. + idle_timeout = 0.0 + return _TurnGuardModel( + model, + max_tool_calls_per_turn=llm.max_tool_calls_per_turn, + stream_idle_timeout=idle_timeout, + ) DEFAULT_MODEL_RETRY = ModelRetrySettings( diff --git a/strix/config/settings.py b/strix/config/settings.py index a4a18b78..f5db30fc 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -57,6 +57,7 @@ class LlmSettings(BaseSettings): alias="LLM_DISABLE_STREAMING", ) timeout: int = Field(default=300, alias="LLM_TIMEOUT") + stream_idle_timeout: int = Field(default=300, ge=0, alias="LLM_STREAM_IDLE_TIMEOUT") max_tool_calls_per_turn: int = Field( default=32, ge=0, diff --git a/tests/test_stream_idle_timeout.py b/tests/test_stream_idle_timeout.py new file mode 100644 index 00000000..9d618978 --- /dev/null +++ b/tests/test_stream_idle_timeout.py @@ -0,0 +1,173 @@ +"""Tests for the model-stream idle watchdog. + +A turn that streams a few tokens and then goes silent is not covered by the +request timeout: the read timeout resets on every byte, keepalives included. +The watchdog bounds the gap between events so the turn fails and can be +retried instead of parking the agent forever. +""" + +from __future__ import annotations + +import asyncio +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import TYPE_CHECKING, Any + +import pytest +from agents.model_settings import ModelSettings +from agents.models.interface import Model, ModelTracing +from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel +from openai import AsyncOpenAI + +from strix.config import loader +from strix.config.loader import load_settings +from strix.config.models import StrixProvider, _TurnGuardModel, _with_idle_timeout + + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Iterator + + +_STALL_SECONDS = 30.0 + + +def _chunk(text: str) -> bytes: + payload = { + "id": "chatcmpl-1", + "object": "chat.completion.chunk", + "created": 0, + "model": "gw-model", + "choices": [{"index": 0, "delta": {"content": text}, "finish_reason": None}], + } + return b"data: " + json.dumps(payload).encode() + b"\n\n" + + +class _StallingHandler(BaseHTTPRequestHandler): + """Streams a couple of tokens, then stops producing anything.""" + + stop = threading.Event() + + def log_message(self, *args: Any) -> None: + pass + + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", 0)) + self.rfile.read(length) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + self.wfile.write(_chunk("Now")) + self.wfile.write(_chunk(" spawning")) + self.wfile.flush() + self.stop.wait(_STALL_SECONDS) + + +@pytest.fixture +def stalling_gateway() -> Iterator[str]: + _StallingHandler.stop.clear() + server = HTTPServer(("127.0.0.1", 0), _StallingHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_address[1]}/v1" + finally: + _StallingHandler.stop.set() + server.shutdown() + server.server_close() + + +def _stream(base_url: str, *, idle_timeout: float) -> AsyncIterator[Any]: + client = AsyncOpenAI(api_key="tok", base_url=base_url, max_retries=0, timeout=_STALL_SECONDS) + inner: Model = OpenAIChatCompletionsModel(model="gw-model", openai_client=client) + guarded = _TurnGuardModel(inner, stream_idle_timeout=idle_timeout) + return guarded.stream_response( + None, + "go", + ModelSettings(), + [], + None, + [], + ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + +async def _drain(base_url: str, *, idle_timeout: float) -> list[Any]: + return [event async for event in _stream(base_url, idle_timeout=idle_timeout)] + + +@pytest.mark.asyncio +async def test_stalled_stream_hangs_without_the_watchdog(stalling_gateway: str) -> None: + # Repro: tokens arrive, then nothing. Un-watched, the turn just sits there; + # the request timeout is far away and would reset on any keepalive byte. + with pytest.raises(TimeoutError): + await asyncio.wait_for(_drain(stalling_gateway, idle_timeout=0), timeout=2) + + +@pytest.mark.asyncio +async def test_stalled_stream_is_abandoned_by_the_watchdog(stalling_gateway: str) -> None: + started = time.monotonic() + with pytest.raises(TimeoutError, match="produced no event"): + await _drain(stalling_gateway, idle_timeout=1) + + assert time.monotonic() - started < _STALL_SECONDS + + +@pytest.mark.asyncio +async def test_events_keep_flowing_while_the_stream_is_alive() -> None: + async def _live() -> AsyncIterator[Any]: + for i in range(5): + await asyncio.sleep(0.05) + yield f"event-{i}" + + seen: list[Any] = [event async for event in _with_idle_timeout(_live(), 1.0)] + + assert seen == [f"event-{i}" for i in range(5)] + + +@pytest.fixture +def _reset_settings(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + for key in ("STRIX_LLM", "LLM_DISABLE_STREAMING", "LLM_STREAM_IDLE_TIMEOUT"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setattr(loader, "_cached", None) + monkeypatch.setattr(loader, "_override", None) + yield + + +class _DummyModel(Model): + async def get_response(self, *args: Any, **kwargs: Any) -> Any: + raise NotImplementedError + + def stream_response(self, *args: Any, **kwargs: Any) -> Any: + raise NotImplementedError + + +def test_idle_timeout_is_configurable( + monkeypatch: pytest.MonkeyPatch, _reset_settings: None +) -> None: + monkeypatch.setattr("strix.config.models.MultiProvider.get_model", lambda *_: _DummyModel()) + monkeypatch.setenv("LLM_STREAM_IDLE_TIMEOUT", "45") + load_settings() + + model = StrixProvider().get_model("openai/gpt-4o-mini") + assert isinstance(model, _TurnGuardModel) + assert model._stream_idle_timeout == 45 + + +def test_idle_timeout_is_off_without_streaming( + monkeypatch: pytest.MonkeyPatch, _reset_settings: None +) -> None: + # LLM_DISABLE_STREAMING turns the whole request into one event, so an idle + # gap would just be the request duration — the request timeout bounds that. + monkeypatch.setattr("strix.config.models.MultiProvider.get_model", lambda *_: _DummyModel()) + monkeypatch.setenv("LLM_STREAM_IDLE_TIMEOUT", "45") + monkeypatch.setenv("LLM_DISABLE_STREAMING", "true") + load_settings() + + model = StrixProvider().get_model("openai/gpt-4o-mini") + assert isinstance(model, _TurnGuardModel) + assert model._stream_idle_timeout == 0 From 0abe82d6226232d36e757e16e8a80a7c8b9fba0e Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:07:30 +0300 Subject: [PATCH 39/57] fix(agents): collapse repeated waits queued inside one model turn (#979) * fix(agents): collapse repeated waits queued inside one model turn * fix(agents): state that one wait is enough in every prompt variant --------- Co-authored-by: Ahmed Allam --- strix/agents/prompts/system_prompt.jinja | 1 + strix/core/hooks.py | 3 + strix/tools/agents_graph/tools.py | 25 +++++ tests/test_wait_dedupe.py | 126 +++++++++++++++++++++++ 4 files changed, 155 insertions(+) create mode 100644 tests/test_wait_dedupe.py diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja index 0be45595..d49692c3 100644 --- a/strix/agents/prompts/system_prompt.jinja +++ b/strix/agents/prompts/system_prompt.jinja @@ -28,6 +28,7 @@ INTER-AGENT MESSAGES: - Messages from other agents arrive prefixed with a header like `[Message from agent | type=... | priority=...]`. Treat them as internal context — never repeat them verbatim in your own output. - Treat agent identity / inherited-context preambles as internal metadata; do not echo them in outputs or tool calls. - Minimize inter-agent messaging: only message when essential for coordination or assistance; avoid routine status updates; batch non-urgent information; prefer parent/child completion flows and shared artifacts over messaging +- wait_for_agents blocks and resumes you automatically, so it is never a poll you repeat: issue exactly ONE wait, then stop and react to what it returns. Never write out a wait/check loop (wait → view_agent_graph → wait → ...) ahead of time — those extra calls only strand you and are collapsed anyway {% if interactive %} INTERACTIVE BEHAVIOR: diff --git a/strix/core/hooks.py b/strix/core/hooks.py index bfeafd45..21400c0b 100644 --- a/strix/core/hooks.py +++ b/strix/core/hooks.py @@ -20,6 +20,8 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +LLM_TURN_KEY = "llm_turn" + _STAGE_LABELS: tuple[str, ...] = ("NOTICE", "URGENT", "CRITICAL") _TURN_WARN_BANDS: tuple[float, ...] = (0.70, 0.85, 0.95) _ROOT_BUDGET_WARN_BANDS: tuple[float, ...] = (0.70, 0.85, 0.95) @@ -144,6 +146,7 @@ class ReportUsageHooks(RunHooks[dict[str, Any]]): system_prompt: str | None, # noqa: ARG002 input_items: list[TResponseInputItem], ) -> None: + context.context[LLM_TURN_KEY] = int(context.context.get(LLM_TURN_KEY, 0)) + 1 try: self._maybe_warn_turns(context, input_items) self._maybe_warn_budget(context, input_items) diff --git a/strix/tools/agents_graph/tools.py b/strix/tools/agents_graph/tools.py index ebfb3846..d4acbc57 100644 --- a/strix/tools/agents_graph/tools.py +++ b/strix/tools/agents_graph/tools.py @@ -14,6 +14,7 @@ from agents import RunContextWrapper, function_tool from strix.core.agents import Status, coordinator_from_context from strix.core.execution import notify_parent_on_terminal +from strix.core.hooks import LLM_TURN_KEY from strix.skills import validate_requested_skills @@ -224,6 +225,7 @@ _WAIT_DEFAULT_TIMEOUT_S = 300 # ``timeout_seconds`` the model asks for. One second of headroom lets the # tool's own timeout fire first and return a clean result. _WAIT_HARD_CEILING_S = _WAIT_DEFAULT_TIMEOUT_S + 1 +_WAITED_TURN_KEY = "waited_llm_turn" @function_tool(timeout=_WAIT_HARD_CEILING_S) @@ -239,6 +241,11 @@ async def wait_for_agents( # noqa: PLR0911 completion reports. You resume the instant any message arrives, so size ``timeout_seconds`` to the work you're awaiting. + **Issue exactly one wait, then stop and react to what it returns.** + This call blocks and resumes on its own; it is not a poll you repeat. + Do not write out a wait/check loop ahead of time — a second wait in + the same turn returns immediately without waiting. + **This tool is only for waiting on other agents.** Two things it is NOT for: @@ -290,6 +297,24 @@ async def wait_for_agents( # noqa: PLR0911 default=str, ) + turn = inner.get(LLM_TURN_KEY) + if turn is not None and inner.get(_WAITED_TURN_KEY) == turn: + return json.dumps( + { + "success": True, + "wait_outcome": "already_waited", + "reason": reason, + "note": ( + "You already waited in this turn. A single wait_for_agents blocks and " + "resumes on its own, so queueing more waits only strands you — issue one " + "wait, then react to what it returns." + ), + }, + ensure_ascii=False, + default=str, + ) + inner[_WAITED_TURN_KEY] = turn + async with coordinator._lock: stopped = coordinator.statuses.get(me) == "stopped" if stopped: diff --git a/tests/test_wait_dedupe.py b/tests/test_wait_dedupe.py new file mode 100644 index 00000000..db97fb4d --- /dev/null +++ b/tests/test_wait_dedupe.py @@ -0,0 +1,126 @@ +"""Tests for collapsing repeated waits queued inside one model turn. + +An orchestrator that writes out its whole poll loop ahead of time queues +many ``wait_for_agents`` calls in a single response. Each one parks for its +full timeout, so the agent stops reacting for hours while its children run +unsupervised. Only the first wait of a turn parks; the rest return at once. +""" + +from __future__ import annotations + +import asyncio +import json +import time +from typing import TYPE_CHECKING, Any, cast + +import pytest +from agents import RunContextWrapper +from agents.tool_context import ToolContext + +from strix.core.agents import AgentCoordinator +from strix.core.hooks import LLM_TURN_KEY, ReportUsageHooks +from strix.tools.agents_graph.tools import wait_for_agents + + +if TYPE_CHECKING: + from collections.abc import Iterator + + +_WAIT_SECONDS = 2 + + +@pytest.fixture +def _fast_wait(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + # The real ceiling is 300s per wait; the shape of the bug is the same. + monkeypatch.setattr( + "strix.tools.agents_graph.tools._WAIT_DEFAULT_TIMEOUT_S", _WAIT_SECONDS, raising=True + ) + yield + + +async def _context() -> dict[str, Any]: + coordinator = AgentCoordinator() + await coordinator.register("root", "strix", parent_id=None) + return {"agent_id": "root", "coordinator": coordinator} + + +async def _wait(inner: dict[str, Any]) -> dict[str, Any]: + ctx = ToolContext( + context=inner, + tool_name="wait_for_agents", + tool_call_id="call-1", + tool_arguments="{}", + ) + raw: str = await wait_for_agents.on_invoke_tool( + ctx, json.dumps({"reason": "waiting for wave 1", "timeout_seconds": _WAIT_SECONDS}) + ) + return cast("dict[str, Any]", json.loads(raw)) + + +@pytest.mark.asyncio +async def test_waits_queued_in_one_turn_each_park_without_the_guard(_fast_wait: None) -> None: + # Repro: no turn marker in context (as before the fix) — every queued wait + # parks for its full timeout, so N waits cost N x timeout. + inner = await _context() + + started = time.monotonic() + outcomes = [(await _wait(inner))["wait_outcome"] for _ in range(3)] + elapsed = time.monotonic() - started + + assert outcomes == ["timeout", "timeout", "timeout"] + assert elapsed >= 3 * _WAIT_SECONDS + + +@pytest.mark.asyncio +async def test_repeated_waits_in_one_turn_are_collapsed(_fast_wait: None) -> None: + inner = await _context() + inner[LLM_TURN_KEY] = 1 + + started = time.monotonic() + outcomes = [(await _wait(inner))["wait_outcome"] for _ in range(3)] + elapsed = time.monotonic() - started + + assert outcomes == ["timeout", "already_waited", "already_waited"] + assert elapsed < 2 * _WAIT_SECONDS + + +@pytest.mark.asyncio +async def test_a_wait_in_the_next_turn_still_parks(_fast_wait: None) -> None: + inner = await _context() + inner[LLM_TURN_KEY] = 1 + assert (await _wait(inner))["wait_outcome"] == "timeout" + assert (await _wait(inner))["wait_outcome"] == "already_waited" + + inner[LLM_TURN_KEY] = 2 + + assert (await _wait(inner))["wait_outcome"] == "timeout" + + +@pytest.mark.asyncio +async def test_each_model_turn_bumps_the_turn_marker() -> None: + hooks = ReportUsageHooks(model="gw-model") + context: RunContextWrapper[dict[str, Any]] = RunContextWrapper(context={}) + agent = cast("Any", None) + + await hooks.on_llm_start(context, agent, None, []) + await hooks.on_llm_start(context, agent, None, []) + + assert context.context[LLM_TURN_KEY] == 2 + + +@pytest.mark.asyncio +async def test_a_collapsed_wait_still_reports_arriving_messages(_fast_wait: None) -> None: + inner = await _context() + inner[LLM_TURN_KEY] = 1 + coordinator = cast("AgentCoordinator", inner["coordinator"]) + + async def _send() -> None: + await asyncio.sleep(0.1) + await coordinator.send("root", {"type": "information", "content": "child done"}) + + task = asyncio.create_task(_send()) + first = await _wait(inner) + await task + + assert first["wait_outcome"] == "message_arrived" + assert (await _wait(inner))["wait_outcome"] == "already_waited" From 97336d53e45645789a9f733d5fa065d0229efa09 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Wed, 5 Aug 2026 17:39:06 +0000 Subject: [PATCH 40/57] feat(reporting): structured reachability evidence ladder for dependency CVE findings --- containers/Dockerfile | 4 +- .../tui/internal/render/dependency.go | 6 + .../skills/custom/dependency_cve_scanning.md | 76 +++++++++++- strix/tools/reporting/tool.py | 93 +++++++++++++- tests/test_reporting_fields.py | 116 ++++++++++++++++++ 5 files changed, 285 insertions(+), 10 deletions(-) diff --git a/containers/Dockerfile b/containers/Dockerfile index 4b3c84d2..9943266a 100644 --- a/containers/Dockerfile +++ b/containers/Dockerfile @@ -16,7 +16,8 @@ RUN mkdir -p /out/bin && \ go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \ go install -v github.com/projectdiscovery/cvemap/cmd/vulnx@latest && \ go install -v github.com/jaeles-project/gospider@latest && \ - go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest + go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest && \ + go install -v golang.org/x/vuln/cmd/govulncheck@latest # --------------------------------------------------------------------------- # Runtime stage @@ -53,6 +54,7 @@ RUN apt-get update && \ nmap ncat ndiff \ sqlmap nuclei subfinder naabu ffuf \ nodejs npm pipx \ + golang-go \ libcap2-bin \ gdb \ libnss3-tools \ diff --git a/strix/interface/tui/internal/render/dependency.go b/strix/interface/tui/internal/render/dependency.go index 78bc6cd4..f41bddc3 100644 --- a/strix/interface/tui/internal/render/dependency.go +++ b/strix/interface/tui/internal/render/dependency.go @@ -57,6 +57,12 @@ func renderDependencyReport(args map[string]any, result any) string { section("Description", StringValue(args["description"])) section("Impact", StringValue(args["impact"])) section("Technical Analysis", StringValue(args["technical_analysis"])) + if reach := StringValue(args["reachability"]); reach != "" && reach != "unknown" { + b.WriteString("\n\n" + Bold(Field).Render("Usage evidence: ") + reach) + if ev := StringValue(args["reachability_evidence"]); ev != "" { + b.WriteString("\n" + ev) + } + } section("Assumptions", StringValue(args["assumptions"])) section("Remediation", StringValue(args["remediation_steps"])) if title == "" { diff --git a/strix/skills/custom/dependency_cve_scanning.md b/strix/skills/custom/dependency_cve_scanning.md index 448af0e8..59e37ecc 100644 --- a/strix/skills/custom/dependency_cve_scanning.md +++ b/strix/skills/custom/dependency_cve_scanning.md @@ -110,15 +110,76 @@ resolution (npm `overrides` / yarn `resolutions` / pnpm `pnpm.overrides` / Maven `dependencyManagement` / Gradle resolution strategy / `go mod edit`), not just "upgrade to ". +### Usage / reachability analysis (required for every dependency CVE) + +For every CVE you are about to report, run a static usage analysis and record +the result in the structured `reachability` + `reachability_evidence` fields. +The level is an **evidence ladder, never an exploitability verdict** — claim +only what you proved, and cite the proof. It never changes severity (that is +`advisory_cvss` alone); it exists so the reader can prioritize. + +**Go — use govulncheck (real call-graph analysis):** + +```bash +# Symbol-level: reports only vulnerabilities whose vulnerable functions are +# actually reachable from application code. Needs the Go toolchain + module +# deps; if either is missing, fall back to the checks below rather than +# claiming a level. +if command -v govulncheck >/dev/null && go version >/dev/null 2>&1; then + govulncheck -format json ./... > "$ART/govulncheck.json" || true +fi +``` + +- A finding with a call stack ⇒ `reachability=reachable_call_path`, put the + call-path excerpt (entrypoint → vulnerable function) in + `reachability_evidence`. +- Listed as affecting a required module but with no reachable symbol ⇒ fall + back to the import/symbol checks below (`imported` / `not_imported`). + +**All other ecosystems — import check, then symbol match:** + +1. **Import check.** Search application code (exclude lockfiles, vendored + deps, `node_modules`, build output) for imports of the vulnerable package: + `ast-grep`/`rg` for `import`/`require`/`from X import` of the package (and + its ecosystem import name, which may differ from the registry name, e.g. + `PyYAML` → `yaml`). No hits ⇒ `not_imported`, with the search scope stated + in `reachability_evidence`. For a **transitive** dependency, the check is + whether application code imports it directly; if not, it is reachable only + through the direct dependency — check whether the direct dep's usage can + hit it (if unclear, use `imported` when the direct dep is used at all). +2. **Symbol match.** Read the advisory (GHSA/NVD/OSV `affected[].ecosystem_specific.imports` or the + advisory text) for the affected functions/classes/APIs. Search application + code for those symbols (`ast-grep` pattern or `rg -n`). Hits ⇒ + `vulnerable_symbol_used`, with repo-relative `file:line` of each hit (up + to a handful) in `reachability_evidence`. Imported but no affected-symbol + usage found (or the advisory names no symbols) ⇒ `imported`. +3. If the analysis was not performed or is inconclusive (obfuscated code, + dynamic loading, unparsable sources) ⇒ `unknown` and say why in + `assumptions`. + +Cheap-first budgeting: the import check is one search per package — always do +it. Do the symbol match at least for every `critical`/`high`/KEV CVE; batch +the searches. Never let this analysis stall reporting — `unknown` with a +reason beats an unverified claim. + +Anti-overclaim rules: + +- `not_imported` still does NOT mean safe (dynamic `import()`/reflection/ + framework wiring evade static search) — never phrase it as "not exploitable". +- `reachable_call_path` is reserved for call-graph tools (govulncheck); a + symbol grep hit is `vulnerable_symbol_used`, no matter how convinced you are. +- The tool rejects any level other than `unknown` without + `reachability_evidence`. + ### Reachability is a confidence modifier, not a gate Do NOT suppress or downgrade a known CVE just because you could not prove the vulnerable code path is reachable. Report it, set `advisory_cvss` from the -advisory, and use `assumptions` to note reachability (e.g. "the vulnerable -`template()` API does not appear to be imported in application code, so practical -exploitability is uncertain"). If you *can* show reachability or chain it into a -dynamic exploit, do that and report it as a normal dynamic finding with -`create_vulnerability_report` instead. +advisory, record the usage analysis in `reachability`/`reachability_evidence`, +and use `assumptions` for anything softer. If you *can* actually trigger the +vulnerable path or chain it into a dynamic exploit, additionally report that +as a normal dynamic finding with `create_vulnerability_report` (the standalone +CVE stays in its own `create_dependency_report`). ## Reporting @@ -152,7 +213,8 @@ findings and rejects empty PoC fields): - Set `cwe` to the most specific `CWE-NNN` when the advisory names one. - Do NOT cap severity at LOW just because there is no dynamic reproduction — use the advisory score. -- Use `assumptions` for reachability/exploitability caveats. +- Set `reachability` + `reachability_evidence` from the usage analysis above; + use `assumptions` for anything softer (confidence, caveats, analysis limits). Verify the CVE with `web_search` when available before reporting. Never guess or hallucinate a CVE id. @@ -168,3 +230,5 @@ hallucinate a CVE id. - Do not silently drop a known CVE because it lacks a dynamic PoC — that is the exact failure this skill prevents. - Do not downgrade advisory severity for lack of dynamic reproduction. +- Do not claim a `reachability` level the evidence does not prove — `unknown` + with a reason is always acceptable; an overclaimed level never is. diff --git a/strix/tools/reporting/tool.py b/strix/tools/reporting/tool.py index a1425315..e2c08c1e 100644 --- a/strix/tools/reporting/tool.py +++ b/strix/tools/reporting/tool.py @@ -719,6 +719,17 @@ def _dependency_severity(advisory_cvss: float | None) -> tuple[float, str]: return score, "none" +_VALID_REACHABILITY = frozenset( + { + "not_imported", + "imported", + "vulnerable_symbol_used", + "reachable_call_path", + "unknown", + } +) + + def _build_dependency_metadata( *, package_name: str, @@ -727,6 +738,8 @@ def _build_dependency_metadata( fixed_version: str | None, introduced_by: str | None, dependency_path: str | None, + reachability: str | None = None, + reachability_evidence: str | None = None, ) -> dict[str, str]: metadata = { "package_name": package_name.strip(), @@ -740,9 +753,25 @@ def _build_dependency_metadata( metadata["introduced_by"] = introduced_by.strip() if dependency_path and dependency_path.strip(): metadata["dependency_path"] = dependency_path.strip() + # "unknown" is the absent case — omitting it keeps the jsonb contract clean, + # and evidence without a level would have nothing to qualify. + if reachability and reachability.strip() and reachability.strip() != "unknown": + metadata["reachability"] = reachability.strip() + if reachability_evidence and reachability_evidence.strip(): + metadata["reachability_evidence"] = reachability_evidence.strip() return metadata +_REACHABILITY_EVIDENCE_LABELS = { + "not_imported": "not imported by application code", + "imported": "imported by application code; affected API usage unconfirmed", + "vulnerable_symbol_used": "the advisory's affected API is used in application code", + "reachable_call_path": ( + "a call path from application code to the vulnerable function was proven" + ), +} + + def _build_dependency_evidence( *, cve: str, @@ -751,6 +780,8 @@ def _build_dependency_evidence( fixed_version: str | None, introduced_by: str | None, dependency_path: str | None, + reachability: str | None = None, + reachability_evidence: str | None = None, ) -> str: evidence = ( f"**Advisory evidence:** `{cve}` applies to `{package_name}` " @@ -765,6 +796,15 @@ def _build_dependency_evidence( ) if dependency_path and dependency_path.strip(): evidence += f"\n\n**Dependency chain:** `{dependency_path.strip()}`" + label = _REACHABILITY_EVIDENCE_LABELS.get((reachability or "").strip().lower()) + if label: + evidence += f"\n\n**Usage analysis:** {label}." + if reachability_evidence and reachability_evidence.strip(): + evidence += f" {reachability_evidence.strip()}" + evidence += ( + " This is a prioritization signal from static analysis, not a" + " proof of exploitability or of safety." + ) return evidence @@ -787,6 +827,8 @@ async def _do_create_dependency( # noqa: PLR0912 fix_effort: str, introduced_by: str | None = None, dependency_path: str | None = None, + reachability: str = "unknown", + reachability_evidence: str | None = None, agent_id: str | None = None, agent_name: str | None = None, ) -> dict[str, Any]: @@ -823,6 +865,18 @@ async def _do_create_dependency( # noqa: PLR0912 f"Invalid fix_effort: {fix_effort!r}. Must be one of: {sorted(_VALID_FIX_EFFORT)}" ) + reachability = (reachability or "unknown").strip().lower() + if reachability not in _VALID_REACHABILITY: + errors.append( + f"Invalid reachability: {reachability!r}. Must be one of: {sorted(_VALID_REACHABILITY)}" + ) + elif reachability != "unknown" and not (reachability_evidence or "").strip(): + errors.append( + "reachability_evidence is required when reachability is not 'unknown': " + "cite the concrete proof (import file:line, matched symbol usage, or " + "govulncheck call path). Never claim a reachability level without evidence." + ) + if advisory_cvss is None: errors.append( "advisory_cvss is required: read the published advisory base score " @@ -843,6 +897,8 @@ async def _do_create_dependency( # noqa: PLR0912 fixed_version=fixed_version, introduced_by=introduced_by, dependency_path=dependency_path, + reachability=reachability, + reachability_evidence=reachability_evidence, ) evidence = _build_dependency_evidence( cve=parsed_cve, @@ -851,6 +907,8 @@ async def _do_create_dependency( # noqa: PLR0912 fixed_version=fixed_version, introduced_by=introduced_by, dependency_path=dependency_path, + reachability=reachability, + reachability_evidence=reachability_evidence, ) try: @@ -949,6 +1007,8 @@ async def create_dependency_report( fix_effort: str = "low", introduced_by: str | None = None, dependency_path: str | None = None, + reachability: str = "unknown", + reachability_evidence: str | None = None, ) -> str: """File a known-CVE dependency (SCA) finding — one report per CVE x package. @@ -973,9 +1033,26 @@ async def create_dependency_report( - Re-reporting the same CVE/package already filed. **Reachability**: do NOT silently downgrade or suppress a finding - because the vulnerable code path may be unreachable — instead state - reachability as an ``assumptions`` / confidence factor. Report the - finding; let the reader weigh exploitability. + because the vulnerable code path may be unreachable — report it, and + record what the usage analysis showed via the structured + ``reachability`` + ``reachability_evidence`` fields (see the + dependency-cve-scanning skill for the analysis procedure). The level + is an evidence ladder, never an exploitability verdict: + + - ``not_imported`` — the package is never imported/required by + application code (strongest de-prioritization signal; still not + proof of safety — dynamic loading, reflection, or framework wiring + can evade static search). + - ``imported`` — application code imports the package, but usage of + the advisory's affected API was not confirmed. + - ``vulnerable_symbol_used`` — the advisory's affected + function/class/API appears in application code. + - ``reachable_call_path`` — a call-graph tool (e.g. ``govulncheck``) + proved a path from application code to the vulnerable function. + - ``unknown`` — usage analysis was not performed or was inconclusive. + + Severity is still derived solely from ``advisory_cvss`` — the + reachability level never changes the rating, only prioritization. **Formatting**: use markdown in text fields (``**bold**``, ``inline code`` for package/version identifiers, fenced code blocks for @@ -1010,6 +1087,14 @@ async def create_dependency_report( to the vulnerable package, joined with `` > `` (e.g. ``express@4.18.1 > body-parser@1.20.0 > qs@6.10.2``). Omit for direct dependencies. + reachability: Usage-evidence level from static analysis — one of + ``not_imported`` / ``imported`` / ``vulnerable_symbol_used`` / + ``reachable_call_path`` / ``unknown``. Claim only what the + evidence proves; when in doubt use ``unknown``. + reachability_evidence: The concrete proof for the claimed level + (required for any level other than ``unknown``): repo-relative + ``file:line`` of the import or symbol usage, the matched + advisory symbols, or the govulncheck call-path excerpt. """ agent_id, agent_name = _caller_identity(ctx) @@ -1031,6 +1116,8 @@ async def create_dependency_report( fix_effort=fix_effort, introduced_by=introduced_by, dependency_path=dependency_path, + reachability=reachability, + reachability_evidence=reachability_evidence, agent_id=agent_id, agent_name=agent_name, ) diff --git a/tests/test_reporting_fields.py b/tests/test_reporting_fields.py index 7bef970b..027c0a9f 100644 --- a/tests/test_reporting_fields.py +++ b/tests/test_reporting_fields.py @@ -255,6 +255,120 @@ async def test_dependency_report_with_zero_cvss_remains_low_severity( assert report["cvss"] == 0.0 +async def test_dependency_report_records_reachability(report_state: ReportState) -> None: + result = await _do_create_dependency( + title="CVE-2021-23337 in lodash 4.17.20", + description="Command injection via template.", + target="repo/package.json", + cve="CVE-2021-23337", + package_name="lodash", + installed_version="4.17.20", + impact="Command injection where template is used.", + remediation_steps="Upgrade to 4.17.21.", + assumptions="Assumes the template sink is reachable.", + package_ecosystem="npm", + fixed_version="4.17.21", + cwe=None, + advisory_cvss=7.2, + technical_analysis=None, + fix_effort="low", + reachability="vulnerable_symbol_used", + reachability_evidence="src/render.ts:14 calls `_.template()`.", + ) + + assert result["success"] is True + report = report_state.vulnerability_reports[0] + assert report["dependency_metadata"]["reachability"] == "vulnerable_symbol_used" + assert ( + report["dependency_metadata"]["reachability_evidence"] + == "src/render.ts:14 calls `_.template()`." + ) + assert "**Usage analysis:**" in report["evidence"] + assert "not a proof of exploitability or of safety" in report["evidence"] + # The level must never influence the rating — that stays advisory_cvss only. + assert report["severity"] == "high" + + +async def test_dependency_report_rejects_reachability_without_evidence( + report_state: ReportState, +) -> None: + result = await _do_create_dependency( + title="CVE-2024-0001 in sample 1.0.0", + description="Published advisory affects the pinned version.", + target="repo/package.json", + cve="CVE-2024-0001", + package_name="sample", + installed_version="1.0.0", + impact="Impact.", + remediation_steps="Upgrade.", + assumptions="Assumptions.", + package_ecosystem="npm", + fixed_version="1.0.1", + cwe=None, + advisory_cvss=5.0, + technical_analysis=None, + fix_effort="low", + reachability="not_imported", + ) + + assert result["success"] is False + assert any("reachability_evidence is required" in e for e in result["errors"]) + assert not report_state.vulnerability_reports + + +async def test_dependency_report_rejects_unknown_reachability_level( + report_state: ReportState, +) -> None: + result = await _do_create_dependency( + title="CVE-2024-0001 in sample 1.0.0", + description="Published advisory affects the pinned version.", + target="repo/package.json", + cve="CVE-2024-0001", + package_name="sample", + installed_version="1.0.0", + impact="Impact.", + remediation_steps="Upgrade.", + assumptions="Assumptions.", + package_ecosystem="npm", + fixed_version="1.0.1", + cwe=None, + advisory_cvss=5.0, + technical_analysis=None, + fix_effort="low", + reachability="not_exploitable", + reachability_evidence="vibes", + ) + + assert result["success"] is False + assert any("Invalid reachability" in e for e in result["errors"]) + assert not report_state.vulnerability_reports + + +async def test_dependency_report_omits_unknown_reachability(report_state: ReportState) -> None: + result = await _do_create_dependency( + title="CVE-2024-0001 in sample 1.0.0", + description="Published advisory affects the pinned version.", + target="repo/package.json", + cve="CVE-2024-0001", + package_name="sample", + installed_version="1.0.0", + impact="Impact.", + remediation_steps="Upgrade.", + assumptions="Analysis was inconclusive.", + package_ecosystem="npm", + fixed_version="1.0.1", + cwe=None, + advisory_cvss=5.0, + technical_analysis=None, + fix_effort="low", + ) + + assert result["success"] is True + metadata = report_state.vulnerability_reports[0]["dependency_metadata"] + assert "reachability" not in metadata + assert "reachability_evidence" not in metadata + + async def test_dependency_report_requires_advisory_cvss(report_state: ReportState) -> None: result = await _do_create_dependency( title="CVE-2024-0001 in sample 1.0.0", @@ -622,6 +736,8 @@ def test_vuln_tool_exposes_new_params() -> None: dep_props = create_dependency_report.params_json_schema["properties"] for field in ("package_name", "installed_version", "cve", "advisory_cvss"): assert field in dep_props + for field in ("reachability", "reachability_evidence"): + assert field in dep_props dep_required = create_dependency_report.params_json_schema["required"] assert "package_ecosystem" in dep_required assert "advisory_cvss" in dep_required From 72cb15a20af962503ac02d0ca475bae0668a825f Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Wed, 5 Aug 2026 22:50:04 +0000 Subject: [PATCH 41/57] feat(reporting): require repo-relative manifest_path on dependency CVE findings --- .../skills/custom/dependency_cve_scanning.md | 6 ++ strix/tools/reporting/tool.py | 37 ++++++++++ tests/test_reporting_fields.py | 72 ++++++++++++++++++- 3 files changed, 114 insertions(+), 1 deletion(-) diff --git a/strix/skills/custom/dependency_cve_scanning.md b/strix/skills/custom/dependency_cve_scanning.md index 59e37ecc..766c41e1 100644 --- a/strix/skills/custom/dependency_cve_scanning.md +++ b/strix/skills/custom/dependency_cve_scanning.md @@ -200,6 +200,12 @@ findings and rejects empty PoC fields): - `package_ecosystem` — normalized ecosystem from `.Results[].Type` (lowercased, e.g. `npm`, `pypi`, `go`, `maven`, `rubygems`, `cargo`) (required). - `fixed_version` — `FixedVersion` (leave empty only if no fix is published). + - `manifest_path` — the repo-relative `Target` lockfile/manifest path + (required). Strip any scan-workspace or repo checkout directory prefix so + the path is relative to the repository root (e.g. `package-lock.json`, + `services/api/pom.xml`); the tool rejects absolute paths and `..` segments. + This binds the finding to the exact file so remediation can target the + right repository. - Reference the repo-relative `Target` lockfile path in `description` / `technical_analysis` (no leading slash) so the finding is traceable. - Put the concrete proof in `description` / `technical_analysis`: package name, diff --git a/strix/tools/reporting/tool.py b/strix/tools/reporting/tool.py index e2c08c1e..12dd2046 100644 --- a/strix/tools/reporting/tool.py +++ b/strix/tools/reporting/tool.py @@ -730,6 +730,25 @@ _VALID_REACHABILITY = frozenset( ) +def _validate_manifest_path(manifest_path: str | None) -> str | None: + """Return an error message when manifest_path is missing or unsafe.""" + path = (manifest_path or "").strip() + if not path: + return ( + "manifest_path is required: pass the repo-relative path of the " + "lockfile/manifest where the vulnerable version was observed " + "(trivy's Target, e.g. 'package-lock.json' or " + "'services/api/pom.xml'). It binds the finding to its exact file " + "so remediation can target the right repository." + ) + if path.startswith("/") or "\\" in path or path.split("/")[0].endswith(":"): + return f"manifest_path must be a relative path within the repository, got {path!r}" + segments = path.split("/") + if any(segment in ("", ".", "..") for segment in segments): + return f"manifest_path must not contain empty, '.', or '..' segments, got {path!r}" + return None + + def _build_dependency_metadata( *, package_name: str, @@ -738,6 +757,7 @@ def _build_dependency_metadata( fixed_version: str | None, introduced_by: str | None, dependency_path: str | None, + manifest_path: str | None = None, reachability: str | None = None, reachability_evidence: str | None = None, ) -> dict[str, str]: @@ -747,6 +767,8 @@ def _build_dependency_metadata( } if package_ecosystem and package_ecosystem.strip(): metadata["package_ecosystem"] = package_ecosystem.strip() + if manifest_path and manifest_path.strip(): + metadata["manifest_path"] = manifest_path.strip() if fixed_version and fixed_version.strip(): metadata["fixed_version"] = fixed_version.strip() if introduced_by and introduced_by.strip(): @@ -827,6 +849,7 @@ async def _do_create_dependency( # noqa: PLR0912 fix_effort: str, introduced_by: str | None = None, dependency_path: str | None = None, + manifest_path: str | None = None, reachability: str = "unknown", reachability_evidence: str | None = None, agent_id: str | None = None, @@ -865,6 +888,10 @@ async def _do_create_dependency( # noqa: PLR0912 f"Invalid fix_effort: {fix_effort!r}. Must be one of: {sorted(_VALID_FIX_EFFORT)}" ) + manifest_err = _validate_manifest_path(manifest_path) + if manifest_err: + errors.append(manifest_err) + reachability = (reachability or "unknown").strip().lower() if reachability not in _VALID_REACHABILITY: errors.append( @@ -897,6 +924,7 @@ async def _do_create_dependency( # noqa: PLR0912 fixed_version=fixed_version, introduced_by=introduced_by, dependency_path=dependency_path, + manifest_path=manifest_path, reachability=reachability, reachability_evidence=reachability_evidence, ) @@ -1001,6 +1029,7 @@ async def create_dependency_report( remediation_steps: str, assumptions: str, package_ecosystem: str, + manifest_path: str | None = None, fixed_version: str | None = None, cwe: str | None = None, technical_analysis: str | None = None, @@ -1087,6 +1116,13 @@ async def create_dependency_report( to the vulnerable package, joined with `` > `` (e.g. ``express@4.18.1 > body-parser@1.20.0 > qs@6.10.2``). Omit for direct dependencies. + manifest_path: **Required.** The repo-relative path of the + lockfile/manifest where the vulnerable version was observed — + trivy's ``Target`` (e.g. ``package-lock.json``, + ``services/api/pom.xml``). Strip any scan-workspace or repo + checkout directory prefix so the path is relative to the + repository root. This binds the finding to its exact file so + remediation can target the right repository. reachability: Usage-evidence level from static analysis — one of ``not_imported`` / ``imported`` / ``vulnerable_symbol_used`` / ``reachable_call_path`` / ``unknown``. Claim only what the @@ -1116,6 +1152,7 @@ async def create_dependency_report( fix_effort=fix_effort, introduced_by=introduced_by, dependency_path=dependency_path, + manifest_path=manifest_path, reachability=reachability, reachability_evidence=reachability_evidence, agent_id=agent_id, diff --git a/tests/test_reporting_fields.py b/tests/test_reporting_fields.py index 027c0a9f..6eda5667 100644 --- a/tests/test_reporting_fields.py +++ b/tests/test_reporting_fields.py @@ -141,6 +141,7 @@ async def test_dependency_report_sets_class_and_metadata(report_state: ReportSta remediation_steps="Upgrade to 4.17.21.", assumptions="Assumes the template sink is reachable.", package_ecosystem="npm", + manifest_path="package-lock.json", fixed_version="4.17.21", cwe="CWE-94", advisory_cvss=7.2, @@ -160,6 +161,7 @@ async def test_dependency_report_sets_class_and_metadata(report_state: ReportSta "package_name": "lodash", "installed_version": "4.17.20", "package_ecosystem": "npm", + "manifest_path": "package-lock.json", "fixed_version": "4.17.21", } @@ -176,6 +178,7 @@ async def test_dependency_report_records_transitive_chain(report_state: ReportSt remediation_steps="Upgrade express to 4.18.2, which resolves qs 6.11.0.", assumptions="qs parses all incoming query strings by default.", package_ecosystem="npm", + manifest_path="package-lock.json", fixed_version="6.10.3", cwe="CWE-1321", advisory_cvss=7.5, @@ -213,6 +216,7 @@ async def test_dependency_report_omits_blank_chain_fields(report_state: ReportSt remediation_steps="Upgrade.", assumptions="Assumptions.", package_ecosystem="npm", + manifest_path="package-lock.json", fixed_version=None, cwe=None, advisory_cvss=5.0, @@ -241,6 +245,7 @@ async def test_dependency_report_with_zero_cvss_remains_low_severity( remediation_steps="Upgrade to 1.0.1.", assumptions="Assumes the package is included in deployed builds.", package_ecosystem="npm", + manifest_path="package-lock.json", fixed_version="1.0.1", cwe=None, advisory_cvss=0.0, @@ -267,6 +272,7 @@ async def test_dependency_report_records_reachability(report_state: ReportState) remediation_steps="Upgrade to 4.17.21.", assumptions="Assumes the template sink is reachable.", package_ecosystem="npm", + manifest_path="package-lock.json", fixed_version="4.17.21", cwe=None, advisory_cvss=7.2, @@ -303,6 +309,7 @@ async def test_dependency_report_rejects_reachability_without_evidence( remediation_steps="Upgrade.", assumptions="Assumptions.", package_ecosystem="npm", + manifest_path="package-lock.json", fixed_version="1.0.1", cwe=None, advisory_cvss=5.0, @@ -330,6 +337,7 @@ async def test_dependency_report_rejects_unknown_reachability_level( remediation_steps="Upgrade.", assumptions="Assumptions.", package_ecosystem="npm", + manifest_path="package-lock.json", fixed_version="1.0.1", cwe=None, advisory_cvss=5.0, @@ -356,6 +364,7 @@ async def test_dependency_report_omits_unknown_reachability(report_state: Report remediation_steps="Upgrade.", assumptions="Analysis was inconclusive.", package_ecosystem="npm", + manifest_path="package-lock.json", fixed_version="1.0.1", cwe=None, advisory_cvss=5.0, @@ -381,6 +390,7 @@ async def test_dependency_report_requires_advisory_cvss(report_state: ReportStat remediation_steps="Upgrade to 1.0.1.", assumptions="Assumes the package ships in deployed builds.", package_ecosystem="npm", + manifest_path="package-lock.json", fixed_version="1.0.1", cwe=None, advisory_cvss=None, @@ -436,6 +446,7 @@ async def test_dependency_report_dedupe_candidate_includes_dependency_metadata( remediation_steps="Upgrade to 1.0.1.", assumptions="Assumes the package is included in deployed builds.", package_ecosystem="npm", + manifest_path="package-lock.json", fixed_version="1.0.1", cwe=None, advisory_cvss=0.0, @@ -453,6 +464,7 @@ async def test_dependency_report_dedupe_candidate_includes_dependency_metadata( "package_name": "sample", "installed_version": "1.0.0", "package_ecosystem": "npm", + "manifest_path": "package-lock.json", "fixed_version": "1.0.1", }, "technical_analysis": None, @@ -471,6 +483,7 @@ async def test_dependency_report_rejects_bad_cve(report_state: ReportState) -> N remediation_steps="r", assumptions="a", package_ecosystem="npm", + manifest_path="package-lock.json", fixed_version=None, cwe=None, advisory_cvss=None, @@ -493,6 +506,7 @@ async def test_dependency_report_requires_ecosystem(report_state: ReportState) - remediation_steps="Upgrade to 1.0.1.", assumptions="Assumes the package is included in deployed builds.", package_ecosystem="", + manifest_path="package-lock.json", fixed_version="1.0.1", cwe=None, advisory_cvss=0.0, @@ -505,6 +519,62 @@ async def test_dependency_report_requires_ecosystem(report_state: ReportState) - assert not report_state.vulnerability_reports +async def test_dependency_report_requires_manifest_path(report_state: ReportState) -> None: + result = await _do_create_dependency( + title="CVE-2024-0001 in sample 1.0.0", + description="Published advisory affects the pinned version.", + target="repo/package.json", + cve="CVE-2024-0001", + package_name="sample", + installed_version="1.0.0", + impact="Low-impact dependency advisory.", + remediation_steps="Upgrade to 1.0.1.", + assumptions="Assumes the package is included in deployed builds.", + package_ecosystem="npm", + manifest_path=None, + fixed_version="1.0.1", + cwe=None, + advisory_cvss=5.0, + technical_analysis=None, + fix_effort="low", + ) + + assert result["success"] is False + assert any("manifest_path is required" in error for error in result["errors"]) + assert not report_state.vulnerability_reports + + +@pytest.mark.parametrize( + "bad_path", + ["/etc/passwd", "..\\pom.xml", "services/../pom.xml", "./package.json", "C:/repo/pom.xml"], +) +async def test_dependency_report_rejects_unsafe_manifest_path( + report_state: ReportState, bad_path: str +) -> None: + result = await _do_create_dependency( + title="CVE-2024-0001 in sample 1.0.0", + description="Published advisory affects the pinned version.", + target="repo/package.json", + cve="CVE-2024-0001", + package_name="sample", + installed_version="1.0.0", + impact="Low-impact dependency advisory.", + remediation_steps="Upgrade to 1.0.1.", + assumptions="Assumes the package is included in deployed builds.", + package_ecosystem="npm", + manifest_path=bad_path, + fixed_version="1.0.1", + cwe=None, + advisory_cvss=5.0, + technical_analysis=None, + fix_effort="low", + ) + + assert result["success"] is False + assert any("manifest_path" in error for error in result["errors"]) + assert not report_state.vulnerability_reports + + def test_dedupe_comparison_preserves_cve_identity() -> None: cleaned = _prepare_report_for_comparison( { @@ -736,7 +806,7 @@ def test_vuln_tool_exposes_new_params() -> None: dep_props = create_dependency_report.params_json_schema["properties"] for field in ("package_name", "installed_version", "cve", "advisory_cvss"): assert field in dep_props - for field in ("reachability", "reachability_evidence"): + for field in ("reachability", "reachability_evidence", "manifest_path"): assert field in dep_props dep_required = create_dependency_report.params_json_schema["required"] assert "package_ecosystem" in dep_required From b69af37cb2df87599c1e07296c7524d331037769 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Wed, 5 Aug 2026 22:57:15 +0000 Subject: [PATCH 42/57] feat(report): keep dependency findings from distinct manifests separate in dedupe --- strix/report/dedupe.py | 20 ++++++ .../skills/custom/dependency_cve_scanning.md | 6 +- tests/test_reporting_fields.py | 66 +++++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/strix/report/dedupe.py b/strix/report/dedupe.py index 23db066f..f848a6d6 100644 --- a/strix/report/dedupe.py +++ b/strix/report/dedupe.py @@ -183,6 +183,24 @@ def _dependency_identity(report: dict[str, Any]) -> tuple[str, str, str] | None: return cve, ecosystem, package_name +def _manifest_path(report: dict[str, Any]) -> str: + metadata = report.get("dependency_metadata") + if not isinstance(metadata, dict): + return "" + return str(metadata.get("manifest_path") or "").strip() + + +def _distinct_manifest_paths(candidate: dict[str, Any], report: dict[str, Any]) -> bool: + """Same CVE/package observed in two different manifests is two findings. + + Only applies when both sides carry a manifest_path; a missing path keeps + the legacy CVE/package/ecosystem identity. + """ + candidate_path = _manifest_path(candidate) + report_path = _manifest_path(report) + return bool(candidate_path and report_path and candidate_path != report_path) + + def _report_cve(report: dict[str, Any]) -> str: return str(report.get("cve") or "").strip().upper() @@ -228,6 +246,8 @@ def _check_dependency_duplicate( report_cve, report_ecosystem, report_package_name = report_identity if (report_cve, report_package_name) != (cve, package_name): continue + if _distinct_manifest_paths(candidate, report): + continue if report_ecosystem == ecosystem: return { "is_duplicate": True, diff --git a/strix/skills/custom/dependency_cve_scanning.md b/strix/skills/custom/dependency_cve_scanning.md index 766c41e1..1303fc0f 100644 --- a/strix/skills/custom/dependency_cve_scanning.md +++ b/strix/skills/custom/dependency_cve_scanning.md @@ -77,8 +77,10 @@ For each entry under `.Results[].Vulnerabilities[]` in `trivy-sca.json`, collect - `CVSS` — the published advisory base score - `PrimaryURL` / references — to verify the advisory -Deduplicate by `(CVE, PkgName, InstalledVersion)`. File one -`create_dependency_report` per CVE — do not batch multiple CVEs into one report. +Deduplicate by `(CVE, PkgName, Target)` — the same CVE/package observed in two +different manifests (e.g. two workspaces of a monorepo) is two findings, one +per manifest. File one `create_dependency_report` per CVE — do not batch +multiple CVEs into one report. ### Attribute transitive CVEs to the direct dependency diff --git a/tests/test_reporting_fields.py b/tests/test_reporting_fields.py index 6eda5667..b52fe4fd 100644 --- a/tests/test_reporting_fields.py +++ b/tests/test_reporting_fields.py @@ -653,6 +653,72 @@ async def test_dependency_dedupe_rejects_same_cve_package_identity() -> None: assert result["confidence"] == 1.0 +async def test_dependency_dedupe_keeps_findings_from_distinct_manifests() -> None: + existing = [ + { + "id": "vuln-0001", + "title": "CVE-2024-0001 in sample", + "cve": "CVE-2024-0001", + "dependency_metadata": { + "package_name": "sample", + "installed_version": "1.0.0", + "package_ecosystem": "npm", + "manifest_path": "services/api/package-lock.json", + }, + } + ] + candidate = { + "title": "CVE-2024-0001 in sample (web)", + "description": "Same advisory observed in a second workspace.", + "target": "repo/package.json", + "cve": "CVE-2024-0001", + "dependency_metadata": { + "package_name": "sample", + "installed_version": "1.0.0", + "package_ecosystem": "npm", + "manifest_path": "services/web/package-lock.json", + }, + } + + result = await check_duplicate(candidate, existing) + + assert result["is_duplicate"] is False + assert result["confidence"] == 1.0 + + +async def test_dependency_dedupe_rejects_same_manifest_identity() -> None: + existing = [ + { + "id": "vuln-0001", + "title": "CVE-2024-0001 in sample", + "cve": "CVE-2024-0001", + "dependency_metadata": { + "package_name": "sample", + "installed_version": "1.0.0", + "package_ecosystem": "npm", + "manifest_path": "services/api/package-lock.json", + }, + } + ] + candidate = { + "title": "CVE-2024-0001 in sample re-reported", + "description": "Same advisory, same manifest.", + "target": "repo/package.json", + "cve": "CVE-2024-0001", + "dependency_metadata": { + "package_name": "sample", + "installed_version": "1.0.0", + "package_ecosystem": "npm", + "manifest_path": "services/api/package-lock.json", + }, + } + + result = await check_duplicate(candidate, existing) + + assert result["is_duplicate"] is True + assert result["duplicate_id"] == "vuln-0001" + + async def test_dependency_dedupe_detects_legacy_same_cve_package() -> None: existing = [ { From 77c7b0df09894514b2f3e80649ff7a8e33b3594e Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 6 Aug 2026 12:32:14 +0000 Subject: [PATCH 43/57] prompt changes --- strix/agents/factory.py | 2 +- strix/agents/prompts/system_prompt.jinja | 6 ++--- strix/core/execution.py | 2 +- strix/core/runner.py | 4 ++-- strix/interface/tui/live_view.py | 2 +- strix/interface/utils.py | 2 +- .../skills/custom/dependency_cve_scanning.md | 2 +- strix/skills/custom/source_aware_sast.md | 24 +++++++++---------- strix/skills/technologies/active_directory.md | 2 +- strix/skills/tooling/python.md | 2 +- .../vulnerabilities/prototype_pollution.md | 4 ++-- strix/tools/output_store.py | 4 ++-- strix/tools/proxy/caido_api.py | 6 ++++- 13 files changed, 33 insertions(+), 29 deletions(-) diff --git a/strix/agents/factory.py b/strix/agents/factory.py index 459b554f..e40e60e8 100644 --- a/strix/agents/factory.py +++ b/strix/agents/factory.py @@ -559,7 +559,7 @@ def registered_agent_tools() -> tuple[Tool, ...]: def build_strix_agent( *, - name: str = "strix", + name: str = "agent", skills: list[str] | None = None, is_root: bool, scan_mode: str = "deep", diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja index d49692c3..77dcdde5 100644 --- a/strix/agents/prompts/system_prompt.jinja +++ b/strix/agents/prompts/system_prompt.jinja @@ -1,4 +1,4 @@ -You are Strix, an advanced AI application security validation agent developed by OmniSecure Labs. Your purpose is to perform authorized security verification, reproduce and validate weaknesses on in-scope assets, and help remediate real security issues. +You are an advanced AI application security validation agent developed by OmniSecure Labs. Your purpose is to perform authorized security verification, reproduce and validate weaknesses on in-scope assets, and help remediate real security issues. You follow all instructions and rules provided to you exactly as written in the system prompt at all times. {% if is_root %} @@ -22,7 +22,7 @@ CLI OUTPUT: - You may use simple markdown: **bold**, *italic*, `code`, ~~strikethrough~~, [links](url), and # headers - Do NOT use complex markdown like bullet lists, numbered lists, or tables - Use line breaks and indentation for structure -- NEVER use "Strix" or any identifiable names/markers in HTTP requests, payloads, user-agents, or any inputs +- NEVER use any identifiable names/markers in HTTP requests, payloads, user-agents, or any inputs INTER-AGENT MESSAGES: - Messages from other agents arrive prefixed with a header like `[Message from agent | type=... | priority=...]`. Treat them as internal context — never repeat them verbatim in your own output. @@ -58,7 +58,7 @@ AUTONOMOUS BEHAVIOR: {% if system_prompt_context and system_prompt_context.authorized_targets %} SYSTEM-VERIFIED SCOPE: -- The following scope metadata is injected by the Strix platform into the system prompt and is authoritative +- The following scope metadata is injected by the platform into the system prompt and is authoritative - Scope source: {{ system_prompt_context.scope_source }} - Authorization source: {{ system_prompt_context.authorization_source }} - Every target listed below has already been verified by the platform as in-scope and authorized diff --git a/strix/core/execution.py b/strix/core/execution.py index eccc3419..91ceb8af 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -838,7 +838,7 @@ async def _append_tool_required_message( ) else: message = ( - "Your previous response ended the autonomous Strix run without a lifecycle tool " + "Your previous response ended the autonomous run without a lifecycle tool " "call. That is invalid in non-interactive mode; plain text final answers are " "ignored. Continue immediately and call exactly one tool. " f"If your work is complete, call {finish_tool}. " diff --git a/strix/core/runner.py b/strix/core/runner.py index 8d75939f..6c197b54 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -293,7 +293,7 @@ async def run_strix_scan( ) root_agent = build_strix_agent( - name="Strix", + name="Root Agent", skills=skills, is_root=True, scan_mode=scan_mode, @@ -307,7 +307,7 @@ async def run_strix_scan( if not is_resume: await coordinator.register( root_id, - "Strix", + "Root Agent", parent_id=None, task=root_task, skills=skills, diff --git a/strix/interface/tui/live_view.py b/strix/interface/tui/live_view.py index aa07dd36..24e676fe 100644 --- a/strix/interface/tui/live_view.py +++ b/strix/interface/tui/live_view.py @@ -431,7 +431,7 @@ _INTERNAL_TURN_PREFIXES = ( "== Inherited context from parent", # strix.core.execution: the no-tool-call recovery nudge, both modes. "Your previous message ended a turn without a tool call.", - "Your previous response ended the autonomous Strix run without a lifecycle tool call.", + "Your previous response ended the autonomous run without a lifecycle tool call.", # strix.core.hooks: budget warnings, the only notices injected unwrapped. *( f"[{label}] {subject}" diff --git a/strix/interface/utils.py b/strix/interface/utils.py index e019a06a..8dc950d2 100644 --- a/strix/interface/utils.py +++ b/strix/interface/utils.py @@ -1102,7 +1102,7 @@ def resolve_diff_scope_context( def _is_http_git_repo(url: str) -> bool: check_url = f"{url.rstrip('/')}/info/refs?service=git-upload-pack" try: - with requests.get(check_url, headers={"User-Agent": "git/strix"}, timeout=10) as resp: + with requests.get(check_url, headers={"User-Agent": "git/2.43.0"}, timeout=10) as resp: if resp.status_code >= 400: return resp.status_code == 401 return "x-git-upload-pack-advertisement" in resp.headers.get("Content-Type", "") diff --git a/strix/skills/custom/dependency_cve_scanning.md b/strix/skills/custom/dependency_cve_scanning.md index 1303fc0f..c0c27438 100644 --- a/strix/skills/custom/dependency_cve_scanning.md +++ b/strix/skills/custom/dependency_cve_scanning.md @@ -28,7 +28,7 @@ Run from the repo root and store output in the shared artifact directory used by the source-aware pass: ```bash -ART=/workspace/.strix-source-aware +ART=/workspace/.source-aware mkdir -p "$ART" # Record the vuln DB age so a stale DB is a visible signal, not a silent clean scan. diff --git a/strix/skills/custom/source_aware_sast.md b/strix/skills/custom/source_aware_sast.md index 0db0e553..992294f6 100644 --- a/strix/skills/custom/source_aware_sast.md +++ b/strix/skills/custom/source_aware_sast.md @@ -12,7 +12,7 @@ Use this skill for source-heavy analysis where static and structural signals sho Run tools from repo root and store outputs in a dedicated artifact directory: ```bash -mkdir -p /workspace/.strix-source-aware +mkdir -p /workspace/.source-aware ``` ## Baseline Coverage Bundle (Recommended) @@ -20,7 +20,7 @@ mkdir -p /workspace/.strix-source-aware Run this baseline once per repository before deep narrowing: ```bash -ART=/workspace/.strix-source-aware +ART=/workspace/.source-aware mkdir -p "$ART" semgrep scan --config p/default --config p/golang --config p/secrets \ @@ -30,7 +30,7 @@ python3 - <<'PY' import json from pathlib import Path -art = Path("/workspace/.strix-source-aware") +art = Path("/workspace/.source-aware") semgrep_json = art / "semgrep.json" targets_file = art / "sg-targets.txt" @@ -70,10 +70,10 @@ Use Semgrep as the default static triage pass: ```bash # Preferred deterministic profile set (works with --metrics=off) semgrep scan --config p/default --config p/golang --config p/secrets \ - --metrics=off --json --output /workspace/.strix-source-aware/semgrep.json . + --metrics=off --json --output /workspace/.source-aware/semgrep.json . # If you choose auto config, do not combine it with --metrics=off -semgrep scan --config auto --json --output /workspace/.strix-source-aware/semgrep-auto.json . +semgrep scan --config auto --json --output /workspace/.source-aware/semgrep-auto.json . ``` If diff scope is active, restrict to changed files first, then expand only when needed. @@ -85,8 +85,8 @@ Use `sg` for structure-aware code hunting: ```bash # Ruleless structural pass over deterministic target list (no sgconfig.yml required) xargs -r -n 200 sg run --pattern '$F($$$ARGS)' --json=stream \ - < /workspace/.strix-source-aware/sg-targets.txt \ - > /workspace/.strix-source-aware/ast-grep.json 2> /workspace/.strix-source-aware/ast-grep.log || true + < /workspace/.source-aware/sg-targets.txt \ + > /workspace/.source-aware/ast-grep.json 2> /workspace/.source-aware/ast-grep.log || true ``` Target high-value patterns such as: @@ -110,15 +110,15 @@ Use outputs to improve route/symbol/sink maps for subsequent targeted scans. Detect hardcoded credentials: ```bash -gitleaks detect --source . --report-format json --report-path /workspace/.strix-source-aware/gitleaks.json -trufflehog filesystem --json . > /workspace/.strix-source-aware/trufflehog.json +gitleaks detect --source . --report-format json --report-path /workspace/.source-aware/gitleaks.json +trufflehog filesystem --json . > /workspace/.source-aware/trufflehog.json ``` Run repository-wide dependency and config checks: ```bash trivy fs --scanners vuln,misconfig --timeout 30m --offline-scan \ - --format json --output /workspace/.strix-source-aware/trivy-fs.json . || true + --format json --output /workspace/.source-aware/trivy-fs.json . || true ``` Known-CVE dependency findings are the one exception to the "report only after @@ -132,9 +132,9 @@ For frontends and Node services, layer these on top of the language-agnostic passes above: ```bash -retire --path . --outputformat json --outputpath /workspace/.strix-source-aware/retire.json || true +retire --path . --outputformat json --outputpath /workspace/.source-aware/retire.json || true eslint --no-config-lookup --rule '{"no-eval":2,"no-implied-eval":2}' \ - -f json -o /workspace/.strix-source-aware/eslint.json . || true + -f json -o /workspace/.source-aware/eslint.json . || true ``` When you hit a minified bundle, run `js-beautify ` for a readable diff --git a/strix/skills/technologies/active_directory.md b/strix/skills/technologies/active_directory.md index 6a14049b..b3962b6d 100644 --- a/strix/skills/technologies/active_directory.md +++ b/strix/skills/technologies/active_directory.md @@ -202,7 +202,7 @@ Confirm with a version/patch check before firing — these are destructive. ## Tooling -**None of the AD tools below ship in the Strix sandbox by default** (the image is Kali-rolling but installs only web-focused tooling). Install what the task needs — the sandbox has `pipx`, `pip`, `go`, `git`, and Kali's apt repos. AD testing also requires **network reachability to the target DC/subnet**, which the default web-target sandbox usually lacks; confirm connectivity first. +**None of the AD tools below ship in the sandbox by default** (the image is Kali-rolling but installs only web-focused tooling). Install what the task needs — the sandbox has `pipx`, `pip`, `go`, `git`, and Kali's apt repos. AD testing also requires **network reachability to the target DC/subnet**, which the default web-target sandbox usually lacks; confirm connectivity first. ``` # Python identity toolkit (impacket = GetUserSPNs/GetNPUsers/secretsdump/ntlmrelayx/getST/addcomputer/rbcd) diff --git a/strix/skills/tooling/python.md b/strix/skills/tooling/python.md index 85a53d9f..b38cdaee 100644 --- a/strix/skills/tooling/python.md +++ b/strix/skills/tooling/python.md @@ -5,7 +5,7 @@ description: Run Python through exec_command in the SDK sandbox. Use the image-b # Python In The Sandbox -Use `exec_command` for Python. There is no separate Strix Python executor. +Use `exec_command` for Python. There is no separate Python executor. Prefer writing reusable scripts to a `.py` file and running them with `python3 .py`. For short one-off transformations, `python3 -c` or a diff --git a/strix/skills/vulnerabilities/prototype_pollution.md b/strix/skills/vulnerabilities/prototype_pollution.md index 2c6ce497..145ed8ce 100644 --- a/strix/skills/vulnerabilities/prototype_pollution.md +++ b/strix/skills/vulnerabilities/prototype_pollution.md @@ -80,7 +80,7 @@ Gadget availability depends on package versions — enumerate `node_modules` in 1. **Identify merge points** — Search for extend/merge/defaults/deep copy on user-controlled objects 2. **Baseline probe** — Inject benign pollution marker: ```json - {"__proto__": {"strixPolluted": "yes"}} + {"__proto__": {"pollutionCanary": "yes"}} ``` Verify via response behavior, error messages, or follow-up request reading shared state 3. **Shape variants** — Test `__proto__`, `constructor.prototype`, nested bracket notation @@ -121,7 +121,7 @@ Gadget availability depends on package versions — enumerate `node_modules` in ## Pro Tips -1. Always verify pollution with a unique canary key (`strixPolluted_`) before attempting RCE gadgets +1. Always verify pollution with a unique canary key (`pollutionCanary_`) before attempting RCE gadgets 2. In white-box scans, grep for `merge`, `extend`, `defaultsDeep`, `assign` with user input 3. Check both request parsing and response template config merges (second-order) 4. Node gadget chains are version-specific — confirm package version before claiming RCE diff --git a/strix/tools/output_store.py b/strix/tools/output_store.py index a525563c..b141e97f 100644 --- a/strix/tools/output_store.py +++ b/strix/tools/output_store.py @@ -1,7 +1,7 @@ """Bound oversized tool results before they enter agent history. Oversized results are spilled into the sandbox at -``/workspace/.strix/tool-output/.txt``; the agent sees a head + tail slice +``/workspace/.tool-output/.txt``; the agent sees a head + tail slice plus the path and reads the rest back with its own file tools. The spill writer is injected by the runner via :func:`configure_spill_writer`. """ @@ -25,7 +25,7 @@ _WORKSPACE_SPILL_NOTICE = ( "in the sandbox; read it with exec_command (e.g. `sed -n`, `grep`, `cat`) ...]" ) -WORKSPACE_SPILL_DIR = "/workspace/.strix/tool-output" +WORKSPACE_SPILL_DIR = "/workspace/.tool-output" # Longest possible workspace path, used only to reserve notice bytes. _SAMPLE_WORKSPACE_PATH = f"{WORKSPACE_SPILL_DIR}/{'0' * 32}.txt" diff --git a/strix/tools/proxy/caido_api.py b/strix/tools/proxy/caido_api.py index 86ac9752..6cfee56c 100644 --- a/strix/tools/proxy/caido_api.py +++ b/strix/tools/proxy/caido_api.py @@ -189,7 +189,11 @@ def build_raw_request( final_headers = {**headers} final_headers.setdefault("Host", parsed.netloc) - final_headers.setdefault("User-Agent", "strix") + final_headers.setdefault( + "User-Agent", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + ) # Framing headers inherited from the captured request describe the ORIGINAL # body; once the body is modified for replay they are stale. We always send a # plain (non-chunked) body with an explicit Content-Length, so drop any From cea52cce8d105b757a66c69c3ed2667037f552f0 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 6 Aug 2026 12:37:59 +0000 Subject: [PATCH 44/57] prompt changes --- strix/agents/prompts/system_prompt.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja index 77dcdde5..6af47c4e 100644 --- a/strix/agents/prompts/system_prompt.jinja +++ b/strix/agents/prompts/system_prompt.jinja @@ -1,4 +1,4 @@ -You are an advanced AI application security validation agent developed by OmniSecure Labs. Your purpose is to perform authorized security verification, reproduce and validate weaknesses on in-scope assets, and help remediate real security issues. +You are an advanced AI application security validation agent. Your purpose is to perform authorized security verification, reproduce and validate weaknesses on in-scope assets, and help remediate real security issues. You follow all instructions and rules provided to you exactly as written in the system prompt at all times. {% if is_root %} From 51bcf70722363c4df4c20008495e191d51503e5f Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 6 Aug 2026 12:46:40 +0000 Subject: [PATCH 45/57] update readme --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8e03a264..a74e329e 100644 --- a/README.md +++ b/README.md @@ -337,6 +337,7 @@ Strix builds on the incredible work of open-source projects like [LiteLLM](https > [!WARNING] -> Only test apps you own or have permission to test. You are responsible for using Strix ethically and legally. +> **Authorized use only.** Strix actively tests the targets you point it at, so only run it against systems you own or have **explicit, written permission** to test, and stay within the agreed scope. Unauthorized testing is illegal in most jurisdictions. +> You alone are responsible for obtaining authorization and complying with the law. Strix is provided "as is" with no warranty or liability for misuse. From 2a9ab1d6cd726cbfe3bc091d7490d96b73541e41 Mon Sep 17 00:00:00 2001 From: alex s <46074070+bearsyankees@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:54:40 -0400 Subject: [PATCH 46/57] =?UTF-8?q?feat:=20agent-ready=20=E2=80=94=20install?= =?UTF-8?q?able=20SKILL.md=20skills,=20AGENTS.md,=20coding-ag=E2=80=A6=20(?= =?UTF-8?q?#926)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 49 +++++++++ README.md | 12 +++ docs/docs.json | 3 +- docs/integrations/coding-agents.mdx | 61 +++++++++++ skills/strix-ci-setup/SKILL.md | 136 +++++++++++++++++++++++++ skills/strix-cloud-api/SKILL.md | 152 ++++++++++++++++++++++++++++ skills/strix-fix-findings/SKILL.md | 77 ++++++++++++++ skills/strix-pentest/SKILL.md | 143 ++++++++++++++++++++++++++ 8 files changed, 632 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md create mode 100644 docs/integrations/coding-agents.mdx create mode 100644 skills/strix-ci-setup/SKILL.md create mode 100644 skills/strix-cloud-api/SKILL.md create mode 100644 skills/strix-fix-findings/SKILL.md create mode 100644 skills/strix-pentest/SKILL.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..0504d63b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,49 @@ +# Strix — Agent Guide + +Strix is an open-source autonomous AI pentesting tool. This file is for AI coding agents that want to **use** Strix (run security scans) or **contribute** to it. + +## Using Strix from an agent + +Install the agent skills for step-by-step workflows: + +```bash +npx skills add usestrix/strix +``` + +- `strix-pentest` — run a headless pentest against code, URLs, domains, or IPs and read results (covers both run modes below) +- `strix-cloud-api` — drive the managed app.strix.ai platform via REST (no local Docker/LLM needed) +- `strix-fix-findings` — remediate findings and re-run Strix to verify +- `strix-ci-setup` — add PR scanning to CI/CD (self-hosted CLI or managed app) + +**Two ways to run, same engine — pick per situation:** + +- **Open-source CLI (self-hosted):** free, fully local, BYO LLM key, needs Docker. Best for local dev loops, air-gapped/offline, and full control. + ```bash + curl -sSL https://strix.ai/install | bash # install + export STRIX_LLM="openai/gpt-5.4" # any LiteLLM model id + export LLM_API_KEY="" + strix -n -t ./ --scan-mode quick --max-budget 10 # headless scan; always use -n + ``` + - Requires Docker running. Scans take minutes (`quick`) to hours (`deep`) — run in the background. + - Exit codes (headless): `0` clean, `1` fatal error, `2` vulnerabilities found. A `0` only covers what was analyzed — check `run.json` (`status`, `llm_usage.cost` vs the budget) before calling a run clean. + - Artifacts in `strix_runs//`: `penetration_test_report.md`, `vulnerabilities/*.md`, `vulnerabilities.json`, `findings.sarif` (SARIF 2.1.0), `run.json`. + +- **Managed cloud (app.strix.ai):** no Docker, no LLM key, no local install; adds team dashboards, scheduling, PR reviews, and downloadable PDF/DOCX reports (Enterprise plan). Best in sandboxed/CI environments and for teams. Use it when local infra isn't available. + ```bash + # token from Settings → API Access; register the target as an asset, then: + curl -sS https://app.strix.ai/api/v1/scans -H "Authorization: Bearer $STRIX_API_TOKEN" \ + -H "Content-Type: application/json" -d '{"engagement_type":"live_test","domain_ids":[""]}' + ``` + - API docs: https://docs.app.strix.ai (OpenAPI: https://docs.app.strix.ai/openapi.json). + +- CLI docs index for LLMs: https://docs.strix.ai/llms.txt (full: https://docs.strix.ai/llms-full.txt). +- Only scan targets the user is authorized to test. + +## Contributing to this repo + +- Python 3.12+, managed with `uv`. Install dev deps: `make dev-install`. +- Lint/format/type-check/security, all in one: `make check-all` (ruff, mypy, bandit). +- Tests: `uv run pytest`. +- Run from source: `uv run strix --target `. +- Layout: `strix/agents` (agent graph + prompts), `strix/tools` (proxy, browser, terminal, scanners), `strix/runtime` (Docker sandbox), `strix/report` (findings, SARIF), `strix/skills` (internal knowledge packs the pentest agents load — different from the consumer skills in `skills/`), `strix/interface` (CLI/TUI), `containers/` (sandbox image). +- Pre-commit hooks: `make pre-commit` (or `uv run pre-commit install`). diff --git a/README.md b/README.md index a74e329e..09668fba 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,18 @@ Try the Strix full-stack penetration testing platform at **[app.strix.ai](https: --- +## 🤖 Use Strix from Your Coding Agent + +Strix is agent-ready. Give Claude Code, Cursor, Codex, or any [SKILL.md-compatible](https://agentskills.io) agent the ability to run pentests, fix findings, and set up CI scanning: + +```bash +npx skills add usestrix/strix +``` + +This installs four skills: **strix-pentest** (run headless scans and read results), **strix-cloud-api** (drive the managed [app.strix.ai](https://app.strix.ai) platform via REST — no local Docker or LLM key), **strix-fix-findings** (remediate + re-scan to verify), and **strix-ci-setup** (PR scanning in CI). Agents can run Strix two ways with the same engine — the open-source CLI locally, or the managed cloud when there's no local infra — and read [`AGENTS.md`](AGENTS.md) for a quick reference, [docs.strix.ai/llms.txt](https://docs.strix.ai/llms.txt) for the CLI docs, and [docs.app.strix.ai](https://docs.app.strix.ai) for the API. + +--- + ## ✨ Features ### Agentic Pentesting Tools diff --git a/docs/docs.json b/docs/docs.json index 23cf2386..de23c158 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -46,7 +46,8 @@ "group": "Integrations", "pages": [ "integrations/github-actions", - "integrations/ci-cd" + "integrations/ci-cd", + "integrations/coding-agents" ] }, { diff --git a/docs/integrations/coding-agents.mdx b/docs/integrations/coding-agents.mdx new file mode 100644 index 00000000..777c28ab --- /dev/null +++ b/docs/integrations/coding-agents.mdx @@ -0,0 +1,61 @@ +--- +title: "Coding Agents" +description: "Use Strix from Claude Code, Cursor, Codex, and other AI agents" +--- + +Strix is built to be driven by AI coding agents. Install the official agent skills and your agent knows how to run pentests, remediate findings, and wire Strix into CI. + +## Install the Skills + +Works with any agent that supports the open [SKILL.md standard](https://agentskills.io) — Claude Code, Cursor, Codex, Gemini CLI, OpenCode, and dozens more: + +```bash +npx skills add usestrix/strix +``` + +| Skill | What your agent learns | +|-------|------------------------| +| `strix-pentest` | Run headless scans against code, URLs, domains, or IPs — self-hosted CLI or managed cloud — with budget caps, and read the results | +| `strix-cloud-api` | Drive the managed [app.strix.ai](https://app.strix.ai) platform over REST — no local Docker or LLM key needed | +| `strix-fix-findings` | Triage findings, fix root causes, and re-run Strix to verify each fix | +| `strix-ci-setup` | Add PR security scanning to GitHub Actions or any CI (self-hosted CLI or managed app) | + +Install a single skill with `npx skills add usestrix/strix --skill strix-pentest`, or use one without installing: + +```bash +npx skills use usestrix/strix@strix-pentest | claude +``` + +## Two ways to run — self-hosted or managed + +Both use the same engine and produce the same validated findings and SARIF, so agents can pick per situation or combine them: + +- **Open-source CLI (self-hosted)** — runs locally in a Docker sandbox with your own LLM key. Free, fully local, air-gap capable. Best for local dev loops and full control. +- **Managed cloud** — runs on Strix's infrastructure via the [app.strix.ai REST API](https://docs.app.strix.ai). No Docker, no LLM key, no local install; adds team dashboards, scheduling, PR reviews, and downloadable PDF/DOCX reports (Enterprise plan). Best in sandboxed/CI environments and for teams. Create an API token under **Settings → API Access**; the `strix-cloud-api` skill has the full flow. + +## Agent-Friendly Interfaces + +Everything an agent needs is machine-readable: + +- **Headless CLI** — `strix -n` runs without the TUI and exits with `0` (clean), `1` (error), or `2` (vulnerabilities found). +- **REST API** — the managed platform exposes a documented [OpenAPI](https://docs.app.strix.ai/openapi.json) at `https://app.strix.ai/api/v1` (scans, vulnerabilities, assets, PR reviews, schedules, webhooks) with bearer tokens and scopes. +- **Structured results** — every run writes `vulnerabilities.json`, `vulnerabilities.csv`, `findings.sarif` (SARIF 2.1.0), and per-finding Markdown under `strix_runs//`; the cloud exposes the same as JSON plus SARIF export. +- **Budget controls** — `--max-budget` and `--max-turns` give agents hard cost/time caps. +- **`AGENTS.md`** — the [repository's agent guide](https://github.com/usestrix/strix/blob/main/AGENTS.md) with a quick reference. +- **`llms.txt`** — this documentation is indexed at [docs.strix.ai/llms.txt](https://docs.strix.ai/llms.txt) and fully exported at [docs.strix.ai/llms-full.txt](https://docs.strix.ai/llms-full.txt); every page is also available as Markdown by appending `.md` to its URL. + +## Example Prompts + +Once the skills are installed, prompts like these just work: + +```text +Pentest this repo with Strix (quick mode, $10 budget) and summarize the findings. +``` + +```text +Fix all critical and high findings from the last Strix run, then re-scan to verify. +``` + +```text +Add Strix security scanning to our GitHub Actions so every PR gets tested. +``` diff --git a/skills/strix-ci-setup/SKILL.md b/skills/strix-ci-setup/SKILL.md new file mode 100644 index 00000000..377b62c0 --- /dev/null +++ b/skills/strix-ci-setup/SKILL.md @@ -0,0 +1,136 @@ +--- +name: strix-ci-setup +description: Wire Strix security scanning into CI/CD — GitHub Actions, GitLab CI, or any pipeline — so every pull request gets a diff-scoped AI pentest that blocks vulnerable code. Covers both the self-hosted open-source CLI (runs in your runner) and the managed app.strix.ai platform (GitHub/GitLab app or API, no runner infra). Use when the user asks to add security scanning, pentesting, or Strix to their CI pipeline or PR workflow. +license: Apache-2.0 +metadata: + author: usestrix + homepage: https://docs.strix.ai +--- + +# Set up Strix in CI/CD + +You can gate PRs two ways — pick based on the environment, or combine them: + +- **Managed platform (recommended for most teams)** — connect the GitHub/GitLab/Bitbucket app once and Strix reviews every PR with **no workflow file, no runner, no Docker, and no LLM key**. Results post as PR comments and land in the team dashboard. Best when you want zero CI maintenance, central tracking, or your runners lack Docker. See "Managed platform" below and the **strix-cloud-api** skill. +- **Self-hosted OSS CLI in your runner** — run a diff-scoped scan as a pipeline step. Fully in your infra, free (BYO LLM key), no external account. Requires Docker on the runner. Best for air-gapped/self-hosted CI or when you don't want scans leaving your environment. + +Both fail the build on validated findings and both emit SARIF 2.1.0, so you can start with one and add the other later. + +--- + +# Option A — Self-hosted OSS CLI in the runner + +Run a diff-scoped Strix scan on every PR: only changed files are tested, `quick` mode keeps it fast, and exit code `2` fails the build when validated vulnerabilities are found. + +## GitHub Actions + +Create `.github/workflows/security.yml`: + +```yaml +name: Security Scan + +on: + pull_request: + +jobs: + strix-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # required for diff-scope resolution + + - name: Install Strix + run: curl -sSL https://strix.ai/install | bash + + - name: Run Security Scan + env: + STRIX_LLM: ${{ secrets.STRIX_LLM }} + LLM_API_KEY: ${{ secrets.LLM_API_KEY }} + run: strix -n -t ./ --scan-mode quick --max-budget 10 + + # Don't fail open: a run that hits the hard budget stop exits 0 but leaves + # run.json status "stopped", not "completed". Enforce completion explicitly. + # This does not catch an agent that wrapped up early on a budget *warning* + # (it still calls finish_scan and records "completed"), so size the budget. + - name: Fail unless the scan completed + run: | + run_json=$(ls -t strix_runs/*/run.json | head -1) + status=$(jq -r .status "$run_json") + if [ "$status" != "completed" ]; then + echo "Strix run status is '$status' — the scan did not complete (likely budget exhausted). Raise --max-budget." >&2 + exit 1 + fi +``` + +Then tell the user to add two repository secrets: `STRIX_LLM` (model id, e.g. `openai/gpt-5.4`) and `LLM_API_KEY` (the provider key). Do not create these values yourself. + +Notes: +- In CI/headless runs Strix automatically scopes to the PR's changed files (`--scope-mode auto`). If diff resolution fails, keep `fetch-depth: 0` or set `--diff-base` to the PR's actual base branch — use `origin/${{ github.base_ref }}` in GitHub Actions rather than a hard-coded `origin/main`, since repos use different default branches. +- Exit codes: `0` pass, `2` vulnerabilities found (fails the job), `1` setup error. +- The runner needs Docker (default GitHub-hosted Ubuntu runners have it). +- **Size the budget so the scan completes — don't let it fail open.** A `0` exit means "no validated vulnerabilities in what was analyzed"; if `--max-budget` is hit before the diff is fully covered, the scan wraps up early and can still exit `0`. The "Fail unless the scan completed" step above narrows the gap: `strix_runs//run.json` is `"stopped"` when the scan was cut off at the hard budget limit without a final report. It is not a complete guard — the agents get graduated wrap-up warnings before that limit, and a run that wraps up on a warning still calls `finish_scan` and records `"completed"` with partial coverage. So keep that step in any pipeline that gates merges **and** give the scan real headroom (compare `run.json`'s `llm_usage.cost` against `--max-budget`; if it ran right up to the cap, raise it). For a `quick` diff-scoped PR scan `--max-budget 10` is usually ample, raise it for large diffs. + +### Optional: upload findings to GitHub code scanning + +Strix writes SARIF 2.1.0 to `strix_runs//findings.sarif`: + +```yaml + - name: Upload SARIF + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: strix_runs +``` + +## Other CI systems + +Any pipeline works the same way — install, set the two env vars, run headless: + +```bash +curl -sSL https://strix.ai/install | bash +# Resolve the PR's base branch robustly (use your CI's base-branch variable if it +# has one, e.g. GitHub Actions: origin/${{ github.base_ref }}). Avoid piping the +# git lookup into another command — a failed lookup would otherwise be masked. +BASE_BRANCH="${CI_MERGE_REQUEST_TARGET_BRANCH_NAME:-}" # GitLab MR target +if [ -z "$BASE_BRANCH" ]; then + BASE_BRANCH=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null) + BASE_BRANCH="${BASE_BRANCH#origin/}" +fi +DIFF_BASE="origin/${BASE_BRANCH:-main}" +# Fail loudly rather than silently narrowing scope (e.g. to HEAD~1, which on a +# multi-commit branch would scan only the last commit and let earlier ones pass). +if ! git rev-parse --verify --quiet "$DIFF_BASE" >/dev/null; then + echo "Cannot resolve diff base '$DIFF_BASE'. Fetch the base branch (git fetch origin ) or set --diff-base explicitly." >&2 + exit 1 +fi +strix -n -t ./ --scan-mode quick --scope-mode diff --diff-base "$DIFF_BASE" --max-budget 10 +``` + +Gate the pipeline on the exit code (see the budget/fail-open caveat above — give the scan enough budget to finish). Schedule `standard` scans nightly and `deep` scans for release candidates. + +--- + +# Option B — Managed platform (no runner infra) + +No workflow file, no Docker, no LLM key. Two ways to use it: + +1. **PR-review app (zero code):** the user installs the Strix GitHub/GitLab/Bitbucket app and enables PR reviews for the repo in the app.strix.ai dashboard. Every PR is then reviewed automatically, with findings posted as PR comments. Nothing to add to the repo. This is the lowest-effort path — recommend it first when the user just wants PR gating. + +2. **API-triggered from any pipeline:** if you want to trigger from an existing pipeline (or a system without the SCM app), call the API with a token that has `pr_reviews:write` (or `scans:write`). Store the token as a CI secret; ask the user to create it at **Settings → API Access**. Example GitHub Actions step: + + ```yaml + - name: Strix PR review (managed) + if: github.event_name == 'pull_request' + env: + STRIX_API_TOKEN: ${{ secrets.STRIX_API_TOKEN }} + run: | + curl -sS --fail https://app.strix.ai/api/v1/pr-reviews/start \ + -H "Authorization: Bearer $STRIX_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"repository_full_name\":\"${{ github.repository }}\",\"pr_number\":${{ github.event.pull_request.number }}}" + ``` + + To gate the build on results, poll the PR review / scan status and fail on unresolved criticals/highs. Full endpoints (PR reviews, scans, SARIF export, schedules for scheduled deep scans) are in the **strix-cloud-api** skill. + +Recommend Option B for most teams (no maintenance, central dashboard); use Option A when scans must stay entirely within your own infrastructure. diff --git a/skills/strix-cloud-api/SKILL.md b/skills/strix-cloud-api/SKILL.md new file mode 100644 index 00000000..b45ef533 --- /dev/null +++ b/skills/strix-cloud-api/SKILL.md @@ -0,0 +1,152 @@ +--- +name: strix-cloud-api +description: Drive the managed Strix platform headlessly through the app.strix.ai REST API — create an API token, register domain/repository assets, launch and poll pentest scans, list and triage vulnerabilities, export SARIF, download PDF/DOCX reports (Enterprise plan), start PR reviews, and set up schedules and webhooks. Use when the user wants Strix without local Docker/LLM infra, or wants scans tracked in a team dashboard, on a schedule, or in CI via API. +license: Apache-2.0 +metadata: + author: usestrix + homepage: https://docs.app.strix.ai +--- + +# Strix Cloud API (managed, no local infra) + +Use this when you want Strix's autonomous pentesting **without running Docker or an LLM yourself** — the scan runs on Strix's infrastructure and results are tracked in a team dashboard. This is the right choice in sandboxed/hosted agent and CI environments, for teams, and for scheduled/continuous testing (downloadable PDF/DOCX reports are an Enterprise-plan feature). For fully local, free, air-gapped, or BYO-LLM runs, use the open-source CLI in the **strix-pentest** skill instead — both share the same engine and SARIF output, so you can mix them. + +Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · OpenAPI: `https://docs.app.strix.ai/openapi.json` + +## Setup + +- **Base URL:** `https://app.strix.ai/api/v1` +- **Auth:** every request sends `Authorization: Bearer `. Tokens are **org-scoped**. +- **Get a token:** the user creates one in the dashboard at **Settings → API Access** (app.strix.ai). Ask them for it; never hardcode, log, or commit it. Store it in an env var or the CI secret store. +- **Scopes (least-privilege):** assign only what the integration needs and rotate regularly: + + | Scope | Grants | + |---|---| + | `scans:read` / `scans:write` | list/read/report scans · create/rerun/cancel scans | + | `vulnerabilities:read` / `:write` | read findings · update status & notes | + | `assets:read` / `:write` | read domains/repos · register/update them | + | `schedules:read` / `:write` | read schedules · create/trigger recurring scans | + | `pr_reviews:write` | trigger PR security reviews | + | `webhooks:read` / `:write` | manage webhook subscriptions | + | `tokens:write` | create/revoke API tokens | + +```bash +export STRIX_API_TOKEN="" +BASE=https://app.strix.ai/api/v1 +auth=(-H "Authorization: Bearer $STRIX_API_TOKEN") +``` + +All examples use `jq` to parse JSON. Handle HTTP errors: `401` bad/expired token, `402` out of credits, `403` scope/plan-tier limit, `422` validation error. + +## 1. Register the target as an asset + +Scans run against **registered assets**, not raw URLs. Register once, then reuse the returned UUID. + +```bash +# Domain (black-box / live target). Requires domain verification before external scanning. +# asset_type must be one of: web_app | api | attack_surface. +curl -sS "$BASE/domains" "${auth[@]}" -H "Content-Type: application/json" \ + -d '{"domain":"staging.example.com","asset_type":"web_app"}' | jq '{id:.domain.id, status, reachable, verification}' + +# Repository (white-box / code review). `full_name` is "owner/name". +# Send one repository object, or a bare JSON array for several — not an object +# wrapping a "repositories" key (that is rejected with 400). +curl -sS "$BASE/repositories" "${auth[@]}" -H "Content-Type: application/json" \ + -d '[{"full_name":"org/app","provider":"github"}]' | jq '.repositories[] | {id, full_name}' +``` + +Look up existing assets instead of re-adding: `GET /domains`, `GET /repositories` (both `assets:read`, paginated with `?page=&limit=`). + +## 2. Launch a scan + +`POST /scans` (`scans:write`). Provide at least one target via `domain_ids`, `repository_ids`, or `internal_targets` (internal infra needs a network connector — see docs). + +```bash +scan_id=$(curl -sS "$BASE/scans" "${auth[@]}" -H "Content-Type: application/json" -d '{ + "engagement_type": "live_test", + "domain_ids": [""], + "focus": "IDOR, auth bypass, SSRF", + "context": "Staging. Test account creds are configured as a test user.", + "notify_on_completion": true +}' | jq -r .scan_id) +echo "$scan_id" +``` + +Useful `CreateScanRequest` fields: + +| Field | Purpose | +|---|---| +| `engagement_type` | `live_test` (default), `code_review`, `internal_infra`, `compliance_pentest` | +| `domain_ids` / `repository_ids` / `internal_targets` | targets (at least one) | +| `domain_paths` / `repository_branches` | narrow to specific paths / branches | +| `credentials` | authenticated scanning, incl. `mfa_method` (`totp`/`email_otp`/…) + `totp_secret` | +| `headers` | extra HTTP headers (e.g. API keys) for the target | +| `focus` / `concerns` / `context` | steer the agents | +| `upload_ids` | attach uploaded source/docs archives for white-box context | +| `notify_on_completion` / `notification_emails` | email when done | + +Response is `{ scan_id, title, status }` with `status` = `pending`. + +## 3. Poll to completion + +`GET /scans/{scanId}` (`scans:read`). Status flow: `pending → running → completed` (or `failed` / `cancelled`). Poll on an interval — scans take minutes to hours; don't block. + +```bash +while :; do + s=$(curl -sS "$BASE/scans/$scan_id" "${auth[@]}" | jq -r .status) + echo "status=$s"; [[ "$s" =~ ^(completed|failed|cancelled)$ ]] && break + sleep 60 +done +``` + +## 4. Read findings + +The scan-detail response includes `executive_summary`, `methodology`, `recommendations`, a `findings` severity roll-up, and a `vulnerabilities[]` array. Each vulnerability carries `title, severity, status, cvss, cwe, endpoint, method, impact, technical_analysis, poc_description, poc_script_code`, and (for code findings) `code_file`/`code_diff`/`code_before`/`code_after`. + +```bash +curl -sS "$BASE/scans/$scan_id" "${auth[@]}" \ + | jq '["critical","high","medium","low","info"] as $order + | .vulnerabilities + | sort_by(.severity as $s | $order | index($s)) + | .[] | {title, severity, endpoint, cwe}' +``` + +Cloud severities are `critical | high | medium | low` and statuses are `open | in_progress | fixed | ignored`. Sort by an explicit severity order rather than `sort_by(.severity)`, which sorts alphabetically (critical, high, low, medium). + +Org-wide triage across scans: `GET /vulnerabilities` (`vulnerabilities:read`; filter by severity/status). Update triage state with the vulnerabilities `:write` endpoints. To remediate, hand off to the **strix-fix-findings** skill. + +## 5. Export & report + +```bash +# SARIF 2.1.0 for GitHub code scanning / ASPM ingestion +curl -sS "$BASE/scans/$scan_id/sarif" "${auth[@]}" -o findings.sarif + +# Report. The format and file type are query params (`Accept` is ignored): +# format=technical (default) | retest | attestation | executive_summary +# type=pdf (default) | docx +# Any report download requires the Enterprise plan; formats beyond `technical`, +# DOCX, and white-label branding are Enterprise-only too. Scan must be completed. +curl -sS "$BASE/scans/$scan_id/report?format=technical&type=pdf" "${auth[@]}" -o strix-report.pdf +``` + +## 6. PR reviews + +Trigger an automated security review of a pull request (`pr_reviews:write`); results appear as PR comments and in the dashboard: + +```bash +curl -sS "$BASE/pr-reviews/start" "${auth[@]}" -H "Content-Type: application/json" \ + -d '{"repository_full_name":"org/app","pr_number":123}' +``` + +List/inspect via `GET /pr-reviews` and `GET /pr-reviews/{id}`. Repo-level PR-review behavior is configured with the repository-settings endpoint. + +## 7. Continuous testing (schedules & webhooks) + +- **Schedules** (`schedules:write`, Pro plan): create recurring scans and trigger them on demand — the managed equivalent of a cron-driven CLI loop. +- **Webhooks** (`webhooks:write`): subscribe to pentest/vulnerability lifecycle events (e.g. `scan.completed`, `vulnerability.created`) to push results into Slack, ticketing, or your own pipeline instead of polling. + +See the schedules and webhooks sections at [docs.app.strix.ai](https://docs.app.strix.ai) for payloads. + +## Safety + +Only scan assets the user's organization owns or is authorized to test. External domain scans require verification (DNS/file/meta-tag) enforced by the platform — don't try to bypass it. diff --git a/skills/strix-fix-findings/SKILL.md b/skills/strix-fix-findings/SKILL.md new file mode 100644 index 00000000..292749d7 --- /dev/null +++ b/skills/strix-fix-findings/SKILL.md @@ -0,0 +1,77 @@ +--- +name: strix-fix-findings +description: Triage and remediate vulnerabilities found by a Strix pentest (open-source CLI or app.strix.ai cloud), then re-run Strix to verify each fix. Use after a Strix scan reports findings, or when the user asks to fix security issues from a strix_runs report, vulnerabilities.json, findings.sarif, or a cloud scan's vulnerabilities. +license: Apache-2.0 +metadata: + author: usestrix + homepage: https://docs.strix.ai +--- + +# Fix Strix findings and verify + +Turn validated Strix findings into minimal, correct fixes — and prove they work by re-scanning. + +## 1. Triage + +Get the findings from wherever the scan ran: + +- **OSS CLI** — artifacts in `strix_runs//`: + - `vulnerabilities/*.md` — one finding per file: description, severity, PoC steps or script, affected code locations, remediation guidance. + - `vulnerabilities.json` — the same findings as JSON (ids, severity, CWE/CVE, `code_locations` with `fix_before`/`fix_after` suggestions when available). +- **Cloud (app.strix.ai)** — fetch the scan's `vulnerabilities[]` via `GET /api/v1/scans/{scanId}` (or `GET /api/v1/vulnerabilities` org-wide). Each carries `severity, cwe, endpoint, method, impact, technical_analysis, poc_description, poc_script_code` and, for code findings, `code_file`/`code_diff`/`code_before`/`code_after`. See the **strix-cloud-api** skill for auth. + +Order work by severity: critical → high → medium → low. Every Strix finding was validated with a working proof-of-concept, so do not dismiss findings as false positives without re-testing the PoC yourself. + +## 2. Fix + +For each finding: + +1. Reproduce it with the PoC from the finding file when feasible. +2. Fix the root cause, not the specific payload (e.g. parameterize all queries, don't blocklist one string; enforce authorization in the handler, don't hide the endpoint). +3. Prefer the framework's built-in defense (ORM parameterization, template auto-escaping, CSRF middleware, centralized authz) over ad-hoc sanitization. +4. Keep the diff minimal and apply the repo's existing patterns. Finding files often include `fix_before`/`fix_after` snippets — use them as a starting point, not verbatim. + +Common finding classes and expected fixes: injection → parameterization/escaping at the sink; IDOR/broken access control → object-level authorization checks; SSRF → allowlist + block internal ranges; XSS → context-aware output encoding + CSP; secrets exposure → rotate the secret AND remove it from code/history; auth issues → fix the server-side check (never client-side). + +## 3. Verify by re-running Strix + +After fixing, re-scan scoped to the fixed area and confirm the finding is gone. Verify in whichever environment you scanned (or both): + +**OSS CLI:** +```bash +# Re-test just the changed files (fast). Resolve the repo's real default +# branch instead of assuming origin/main (many repos use master/develop). +# Avoid the current branch's own upstream as the base — its merge base with +# HEAD would be HEAD, giving an empty diff and a falsely clean result. +DIFF_BASE=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null) +# origin/HEAD can be a dangling symbolic ref — keep it only if its target exists. +git rev-parse --verify --quiet "$DIFF_BASE" >/dev/null 2>&1 || DIFF_BASE="" +if [ -z "$DIFF_BASE" ]; then + for b in origin/main origin/master origin/develop; do + git rev-parse --verify --quiet "$b" >/dev/null && DIFF_BASE="$b" && break + done +fi +# No silent fallback: a guess like HEAD~1 would cover only the last commit of a +# multi-commit fix branch. If no base resolves, ask the user for the base branch +# (or use the focused --instruction verification below, which needs no diff base). +[ -n "$DIFF_BASE" ] || { echo "Set DIFF_BASE to the branch your fix will merge into." >&2; exit 1; } +strix -n -t ./ --scan-mode quick --scope-mode diff --diff-base "$DIFF_BASE" --max-budget 5 + +# Or re-test with the original finding as focus (no diff base needed) +strix -n -t ./ --instruction "Verify the SQL injection in app/api/search.py is fixed. Original PoC: " --max-budget 5 +``` +Exit codes: `2` = findings remain (read the new `strix_runs//vulnerabilities/` and iterate); `0` = clean **for what was analyzed**. Before trusting a `0`, confirm the run wasn't cut short — check `run.json` for a completed status and compare its `llm_usage.cost` with `--max-budget`: a hard budget stop leaves `status: "stopped"`, but a run that wrapped up on a budget warning records `"completed"` with partial coverage. Give verification enough budget to finish, and prefer re-running the specific PoC as the ground-truth signal. + +**Cloud:** rerun with the same config and re-poll, then confirm the finding no longer appears: +```bash +new_id=$(curl -sS "$BASE/scans/$scan_id/rerun" "${auth[@]}" -X POST | jq -r .scan_id) +# poll GET /scans/$new_id until completed, then check its vulnerabilities[] +``` +Or, if the cloud scan came from a repo/PR, trigger a fresh PR review on the fix branch (`POST /pr-reviews/start`). The platform also retests a single finding directly: `POST /api/v1/vulnerabilities/{vulnerabilityId}/retest`. + +- Also re-run the PoC manually when it is a simple request/script — fastest signal. +- Run the project's own test suite to make sure the fix doesn't break behavior. + +## 4. Report + +Summarize per finding: severity, root cause, fix applied (file:line), verification result (re-scan clean / PoC no longer reproduces). Never include live secrets in the report; if a secret leaked, state that rotation is required. diff --git a/skills/strix-pentest/SKILL.md b/skills/strix-pentest/SKILL.md new file mode 100644 index 00000000..56441c2c --- /dev/null +++ b/skills/strix-pentest/SKILL.md @@ -0,0 +1,143 @@ +--- +name: strix-pentest +description: Run an autonomous AI penetration test with Strix against a codebase, repository, URL, domain, or IP — either self-hosted with the open-source CLI or via the managed app.strix.ai cloud API — and read the validated findings (Markdown, JSON, CSV, SARIF, PoCs). Use when the user asks to pentest, security-scan, or find vulnerabilities in an app, API, website, or repo with Strix. +license: Apache-2.0 +metadata: + author: usestrix + homepage: https://docs.strix.ai +--- + +# Run a Strix pentest + +Strix runs autonomous AI pentesting agents that dynamically exploit a target and only report findings validated with a working proof-of-concept. There are **two ways to run it, built on the same engine and producing the same findings** — pick per situation, and mix them freely: + +- **Open-source CLI** (self-hosted) — runs on your machine in a Docker sandbox with your own LLM key. Free, fully local, BYO-LLM, air-gap capable. Docs: [docs.strix.ai](https://docs.strix.ai). +- **Cloud API** (managed) — runs on Strix's infrastructure via `https://app.strix.ai/api/v1`. No Docker, no LLM key, no local compute; adds team dashboards, scheduling, PR reviews, downloadable PDF/DOCX reports (Enterprise plan), and internal-network connectors. Docs: [docs.app.strix.ai](https://docs.app.strix.ai). Full workflow in the **strix-cloud-api** skill. + +## Which one? (decide, don't default) + +Choose honestly based on the situation — neither is "better": + +| Situation | Prefer | +|---|---| +| No Docker available, or a sandboxed/hosted agent/CI environment | **Cloud** | +| User has no LLM key / doesn't want to pay per-token or manage models | **Cloud** | +| Team visibility, shareable dashboard, scheduled/continuous scans, PR reviews, downloadable PDF/DOCX report (Enterprise) | **Cloud** | +| Scanning internal/private infrastructure not reachable from your machine | **Cloud** (network connector) | +| Source must never leave local infra (privacy/air-gap), or fully offline | **OSS CLI** | +| Free / one-off / local dev-loop scan, Docker already present | **OSS CLI** | +| BYO or self-hosted LLM, or a specific model not offered by the platform | **OSS CLI** | +| CI: runner already has Docker and you want a self-contained gate | **OSS CLI** | +| CI: no Docker, or you want results tracked centrally | **Cloud** | + +**Mix them:** e.g. use the OSS CLI for the fast local dev-loop while writing/fixing code, and the Cloud for the authoritative, team-visible scan + report + tracking; or gate PRs with the OSS CLI in CI while the Cloud runs scheduled deep scans and PR reviews across the org. Both emit the same SARIF 2.1.0, so findings line up across environments. + +If unsure and the user has (or will create) an app.strix.ai account, prefer **Cloud** — it avoids all local-infra friction. If they want zero signup / full local control, use the **OSS CLI**. + +--- + +# Option A — Open-source CLI (self-hosted) + +## Prerequisites + +1. **Docker running** — check with `docker info`. The first scan pulls the sandbox image automatically. +2. **Strix installed** — check with `strix --version`. Install if missing: + ```bash + curl -sSL https://strix.ai/install | bash # or: pipx install strix-agent + ``` +3. **LLM configured** — two environment variables: + ```bash + export STRIX_LLM="openai/gpt-5.4" # any LiteLLM model id (openai/..., anthropic/..., openrouter/...) + export LLM_API_KEY="" + ``` + Ask the user for these if unset. Never hardcode or commit keys. + +## Running a scan + +Always use `-n` (non-interactive/headless) — the default TUI blocks agents. Always set `--max-budget` unless the user says otherwise. + +```bash +# Local code (white-box) +strix -n -t ./ --scan-mode standard --max-budget 10 + +# Deployed app / API (black-box) +strix -n -t https://staging.example.com --max-budget 20 + +# Repo + deployed app together (best coverage) +strix -n -t https://github.com/org/app -t https://staging.example.com + +# Focused testing with credentials or scope hints +strix -n -t https://app.example.com \ + --instruction "Use credentials user@example.com:pass123. Focus on IDOR and auth bypass." + +# Large monorepo: bind-mount instead of copying +strix -n --mount ./huge-monorepo +``` + +Key flags: + +| Flag | Meaning | +|---|---| +| `-t, --target` | URL, repo URL, local path, domain, or IP. Repeatable. | +| `-n, --non-interactive` | Headless, exits on completion. Required for agents. | +| `-m, --scan-mode` | `quick` (minutes) / `standard` (~30 min) / `deep` (hours, default). | +| `--instruction` / `--instruction-file` | Credentials, focus areas, scope rules. | +| `--max-budget USD` | Hard LLM spend cap; scan wraps up cleanly at the limit. | +| `--max-turns N` | Per-agent turn cap (default 500). | +| `--resume RUN_NAME` | Resume a prior run from `strix_runs/`. | + +Scans take minutes (`quick`) to hours (`deep`). Run them in the background and poll for completion rather than blocking. + +### Exit codes (headless) + +- `0` — finished with no validated vulnerabilities **in what was analyzed** +- `1` — fatal error (missing env vars, Docker down, bad config) +- `2` — vulnerabilities found + +A `0` is not proof of full coverage: if `--max-budget`/`--max-turns` is reached before the scan completes, it wraps up early and still exits `0`. When you need assurance the scan finished, give it enough budget and check `strix_runs//run.json`: a hard budget stop leaves `status: "stopped"`, but an agent that wrapped up early on a budget *warning* still calls `finish_scan` and records `"completed"` — so also sanity-check the run's cost against `--max-budget` and the report's stated coverage before treating a clean result as full coverage. + +### Reading results + +Artifacts land in `strix_runs//`: + +| File | Contents | +|---|---| +| `penetration_test_report.md` | Executive report — read this first. | +| `vulnerabilities/*.md` | One file per validated finding, with PoC and remediation. | +| `vulnerabilities.json` / `vulnerabilities.csv` | All findings as structured JSON / CSV index. | +| `findings.sarif` | SARIF 2.1.0 for GitHub code scanning / ASPM ingestion. | +| `run.json` | Run metadata, status, targets, usage/cost. | + +--- + +# Option B — Cloud API (managed, no local infra) + +Full details, asset registration, polling, reports, PR reviews, schedules, and webhooks are in the **strix-cloud-api** skill. Minimal launch-and-poll: + +```bash +export STRIX_API_TOKEN="" # org-scoped bearer, from Settings → API Access at app.strix.ai +BASE=https://app.strix.ai/api/v1 + +# 1. Launch a scan against an already-registered domain/repo asset +scan_id=$(curl -sS "$BASE/scans" \ + -H "Authorization: Bearer $STRIX_API_TOKEN" -H "Content-Type: application/json" \ + -d '{"engagement_type":"live_test","domain_ids":[""]}' | jq -r .scan_id) + +# 2. Poll until terminal (pending → running → completed/failed/cancelled) +curl -sS "$BASE/scans/$scan_id" -H "Authorization: Bearer $STRIX_API_TOKEN" | jq '.status' + +# 3. Read validated findings from the scan detail's `vulnerabilities[]`, or export SARIF +curl -sS "$BASE/scans/$scan_id/sarif" -H "Authorization: Bearer $STRIX_API_TOKEN" -o findings.sarif +``` + +Ask the user to create the token (and register the target as a domain/repository asset) if they haven't. If Docker/local prerequisites aren't already satisfied, use this path instead of trying to install infra. + +--- + +## Reporting & next steps + +Summarize findings by severity (critical/high/medium/low/info) and include the PoC evidence. To remediate and verify fixes (via either path), use the **strix-fix-findings** skill. To wire scanning into CI/CD, use the **strix-ci-setup** skill. + +## Safety + +Only scan targets the user owns or is authorized to test. The Cloud platform enforces domain verification before external scans; for the OSS CLI, confirm authorization yourself if the target looks like third-party infrastructure. From ec07f0f68ffc0f43c886a8010a1f6f06b3dc58f3 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 6 Aug 2026 13:55:15 +0000 Subject: [PATCH 47/57] docs(skills): require per-CVE affected-symbol matching in dependency reachability analysis --- strix/skills/custom/dependency_cve_scanning.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/strix/skills/custom/dependency_cve_scanning.md b/strix/skills/custom/dependency_cve_scanning.md index c0c27438..816dd3a6 100644 --- a/strix/skills/custom/dependency_cve_scanning.md +++ b/strix/skills/custom/dependency_cve_scanning.md @@ -149,19 +149,27 @@ fi whether application code imports it directly; if not, it is reachable only through the direct dependency — check whether the direct dep's usage can hit it (if unclear, use `imported` when the direct dep is used at all). -2. **Symbol match.** Read the advisory (GHSA/NVD/OSV `affected[].ecosystem_specific.imports` or the - advisory text) for the affected functions/classes/APIs. Search application - code for those symbols (`ast-grep` pattern or `rg -n`). Hits ⇒ +2. **Symbol match — per CVE, not per package.** Read each CVE's own advisory + (GHSA/NVD/OSV `affected[].ecosystem_specific.imports` or the advisory + text) for the affected functions/classes/APIs. Search application code for + those symbols (`ast-grep` pattern or `rg -n`). Hits ⇒ `vulnerable_symbol_used`, with repo-relative `file:line` of each hit (up to a handful) in `reachability_evidence`. Imported but no affected-symbol usage found (or the advisory names no symbols) ⇒ `imported`. + Different CVEs on the same package usually affect **different** symbols + (one hits a parser, another a header check) — never copy one CVE's + verdict/evidence onto its siblings; run the symbol search against each + CVE's own affected-symbol list. The import check (step 1) is the only + part shared across a package's CVEs. 3. If the analysis was not performed or is inconclusive (obfuscated code, dynamic loading, unparsable sources) ⇒ `unknown` and say why in `assumptions`. Cheap-first budgeting: the import check is one search per package — always do -it. Do the symbol match at least for every `critical`/`high`/KEV CVE; batch -the searches. Never let this analysis stall reporting — `unknown` with a +it. Do the per-CVE symbol match for every CVE whose advisory names affected +symbols (they can be batched into one multi-pattern search per package); +prioritize `critical`/`high`/KEV when the budget is tight and leave the rest +at `imported`. Never let this analysis stall reporting — `unknown` with a reason beats an unverified claim. Anti-overclaim rules: From 709a7a1b39152630457f71540973eb56db2ffdfa Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 6 Aug 2026 13:58:44 +0000 Subject: [PATCH 48/57] docs(skills): skipped symbol search must be disclosed in reachability evidence --- strix/skills/custom/dependency_cve_scanning.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/strix/skills/custom/dependency_cve_scanning.md b/strix/skills/custom/dependency_cve_scanning.md index 816dd3a6..129f4a1e 100644 --- a/strix/skills/custom/dependency_cve_scanning.md +++ b/strix/skills/custom/dependency_cve_scanning.md @@ -168,9 +168,12 @@ fi Cheap-first budgeting: the import check is one search per package — always do it. Do the per-CVE symbol match for every CVE whose advisory names affected symbols (they can be batched into one multi-pattern search per package); -prioritize `critical`/`high`/KEV when the budget is tight and leave the rest -at `imported`. Never let this analysis stall reporting — `unknown` with a -reason beats an unverified claim. +prioritize `critical`/`high`/KEV when the budget is tight; a CVE whose symbol +search was skipped may still be reported as `imported` (the import check is +real evidence), but its `reachability_evidence` must state that the +affected-symbol check was not performed, so a skipped search is never +mistaken for a completed one with no hits. Never let this analysis stall +reporting — `unknown` with a reason beats an unverified claim. Anti-overclaim rules: From e71bf127fdac1ba4bf40e8cf8767b4a21a5ba351 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 6 Aug 2026 14:50:28 +0000 Subject: [PATCH 49/57] chore: release v1.5.0 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bcc91b03..1c88a1a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "strix-agent" -version = "1.4.1" +version = "1.5.0" description = "Open-source AI Hackers for your apps" readme = "README.md" license = "Apache-2.0" diff --git a/uv.lock b/uv.lock index 399dc00e..ac40732e 100644 --- a/uv.lock +++ b/uv.lock @@ -2378,7 +2378,7 @@ wheels = [ [[package]] name = "strix-agent" -version = "1.4.1" +version = "1.5.0" source = { editable = "." } dependencies = [ { name = "caido-sdk-client" }, From 28747e682e15d6a059d6af2a6029626f815d8523 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 6 Aug 2026 14:54:11 +0000 Subject: [PATCH 50/57] chore(image): bump sandbox tag 1.2.0 -> 1.3.0 --- docs/advanced/configuration.mdx | 2 +- scripts/install.sh | 2 +- strix/config/settings.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index f83cb4a7..f1542b75 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -110,7 +110,7 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th ## Docker Configuration - + Docker image to use for the sandbox container. diff --git a/scripts/install.sh b/scripts/install.sh index aee906bb..5e2e8a71 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,7 +4,7 @@ set -euo pipefail APP=strix REPO="usestrix/strix" -STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.2.0" +STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.3.0" MUTED='\033[0;2m' RED='\033[0;31m' diff --git a/strix/config/settings.py b/strix/config/settings.py index f5db30fc..42a2c97e 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -106,7 +106,7 @@ class RuntimeSettings(BaseSettings): model_config = _BASE_CONFIG image: str = Field( - default="ghcr.io/usestrix/strix-sandbox:1.2.0", + default="ghcr.io/usestrix/strix-sandbox:1.3.0", alias="STRIX_IMAGE", ) backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND") From c6c8bb5ca60e1740879c143602e1bb25dca1a215 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 6 Aug 2026 18:17:19 +0000 Subject: [PATCH 51/57] ci: match Windows backslash paths in the release TUI-sidecar check --- .github/workflows/build-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 9b1f267f..c9e05533 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -70,7 +70,7 @@ jobs: TUI_NAME="strix-tui" dist/strix --version fi - uv run pyi-archive_viewer -l "$PYI_BINARY" | grep "strix/bin/$TUI_NAME" >/dev/null + uv run pyi-archive_viewer -l "$PYI_BINARY" | grep -E "strix[/\\]bin[/\\]$TUI_NAME" >/dev/null if [[ "${{ matrix.target }}" == "linux-arm64" ]]; then file dist/strix From bda0f5434207007b32f3c2b74429f95f1336e048 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 6 Aug 2026 18:42:44 +0000 Subject: [PATCH 52/57] ci: tolerate repr-escaped backslashes in the release TUI-sidecar check --- .github/workflows/build-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index c9e05533..39350337 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -70,7 +70,7 @@ jobs: TUI_NAME="strix-tui" dist/strix --version fi - uv run pyi-archive_viewer -l "$PYI_BINARY" | grep -E "strix[/\\]bin[/\\]$TUI_NAME" >/dev/null + uv run pyi-archive_viewer -l "$PYI_BINARY" | grep -E "strix[/\\]+bin[/\\]+$TUI_NAME" >/dev/null if [[ "${{ matrix.target }}" == "linux-arm64" ]]; then file dist/strix From b08662449d3a073377576b981d13f2636f40bd92 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 6 Aug 2026 19:01:16 +0000 Subject: [PATCH 53/57] ci: publish nested standalone archives as release assets --- .github/workflows/build-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 39350337..9e5292b5 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -118,4 +118,4 @@ jobs: with: prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }} generate_release_notes: true - files: release/* + files: release/** From 9dae76667bf703784948f188fc74fb245d62bec4 Mon Sep 17 00:00:00 2001 From: alex s <46074070+bearsyankees@users.noreply.github.com> Date: Fri, 7 Aug 2026 02:22:06 -0400 Subject: [PATCH 54/57] expand firebase storage rules coverage (#1002) --- docs/advanced/skills.mdx | 8 +- strix/agents/prompts/system_prompt.jinja | 6 +- strix/skills/__init__.py | 79 +++++++++-- strix/skills/cloud/gcp.md | 2 +- .../{firebase_firestore.md => firebase.md} | 74 ++++++++-- tests/test_skill_dir_extension.py | 128 +++++++++++++++++- 6 files changed, 264 insertions(+), 33 deletions(-) rename strix/skills/technologies/{firebase_firestore.md => firebase.md} (64%) diff --git a/docs/advanced/skills.mdx b/docs/advanced/skills.mdx index 8d9ced96..ce624424 100644 --- a/docs/advanced/skills.mdx +++ b/docs/advanced/skills.mdx @@ -68,10 +68,10 @@ Framework-specific testing patterns. Third-party service and platform security. -| Skill | Coverage | -| -------------------- | ---------------------------------- | -| `supabase` | Supabase RLS bypasses, auth issues | -| `firebase_firestore` | Firestore rules, Firebase auth | +| Skill | Coverage | +| ---------- | ------------------------------------------------------ | +| `supabase` | Supabase RLS bypasses, auth issues | +| `firebase` | Firebase Firestore, Storage rules, Auth, and Functions | ### Protocols diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja index 6af47c4e..5fc697d7 100644 --- a/strix/agents/prompts/system_prompt.jinja +++ b/strix/agents/prompts/system_prompt.jinja @@ -490,8 +490,10 @@ Default user: pentester (sudo available) On-demand specialist skills. Spawn a specialist via `create_agent(skills=[...])`, or pull guidance inline for yourself via `load_skill(skills=[...])`. Anything wrapped in `` above is already loaded for you. -{% for category, names in available_skills | dictsort -%} -- {{ category }}: {{ names | join(', ') }} +{% for category, skills in available_skills | dictsort -%} +{% for skill in skills -%} +- {{ category }}/{{ skill.name }}{% if skill.description %}: {{ skill.description }}{% endif %} +{% endfor -%} {% endfor -%} {% endif %} diff --git a/strix/skills/__init__.py b/strix/skills/__init__.py index 31ac3059..0adf3d99 100644 --- a/strix/skills/__init__.py +++ b/strix/skills/__init__.py @@ -4,6 +4,9 @@ import threading from collections import Counter from collections.abc import Iterator from pathlib import Path +from typing import TypeGuard + +import yaml from strix.telemetry import posthog, scarf from strix.utils.resource_paths import get_strix_resource_path @@ -11,12 +14,17 @@ from strix.utils.resource_paths import get_strix_resource_path logger = logging.getLogger(__name__) -_FRONTMATTER_PATTERN = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL) +_FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(?P.*?)\n---\s*\n", re.DOTALL) _INTERNAL_SKILL_CATEGORIES: frozenset[str] = frozenset({"scan_modes", "coordination"}) _ROOT_SKILL_CATEGORY = "root" _EXTRA_SKILL_DIRS: list[Path] = [] +_SKILL_METADATA_CACHE: dict[tuple[Path, int, int], dict[str, str]] = {} + + +def _is_frontmatter_mapping(value: object) -> TypeGuard[dict[object, object]]: + return isinstance(value, dict) def register_skill_dir(path: str | Path) -> None: @@ -109,13 +117,18 @@ def _get_ambiguous_skill_names() -> set[str]: return {name for name, count in counts.items() if count > 1} -def _qualified_skill_files(skill_name: str) -> list[Path]: +def _qualified_skill_file_for_name(skill_name: str) -> Path | None: category, _, name = skill_name.partition("/") for skills_dir in skill_search_dirs(): candidate = _qualified_skill_file(skills_dir, category, name) if candidate is not None: - return [candidate] - return [] + return candidate + return None + + +def _qualified_skill_files(skill_name: str) -> list[Path]: + candidate = _qualified_skill_file_for_name(skill_name) + return [candidate] if candidate is not None else [] def _bare_skill_files(skill_name: str) -> list[Path]: @@ -145,10 +158,59 @@ def _bare_skill_files(skill_name: str) -> list[Path]: return candidates -def get_available_skills() -> dict[str, list[str]]: - grouped: dict[str, list[str]] = {} +def _parse_skill_content(content: str, source: Path | None = None) -> tuple[dict[str, str], str]: + """Parse skill frontmatter once and return metadata plus markdown body.""" + frontmatter = _FRONTMATTER_PATTERN.match(content) + if frontmatter is None: + return {}, content.lstrip() + + try: + parsed: object = yaml.safe_load(frontmatter.group("body")) + except yaml.YAMLError as error: + logger.warning("Failed to parse skill frontmatter %s: %s", source or "", error) + parsed = None + if not _is_frontmatter_mapping(parsed): + logger.warning("Skill frontmatter is not a mapping: %s", source or "") + return {}, content[frontmatter.end() :].lstrip() + + metadata = {str(key): "" if value is None else str(value) for key, value in parsed.items()} + return metadata, content[frontmatter.end() :].lstrip() + + +def _read_skill_metadata(file_path: Path) -> dict[str, str]: + try: + stat = file_path.stat() + except OSError: + logger.warning("Skill file disappeared while reading metadata: %s", file_path) + return {} + cache_key = (file_path, stat.st_mtime_ns, stat.st_size) + cached = _SKILL_METADATA_CACHE.get(cache_key) + if cached is not None: + return cached + try: + content = file_path.read_text(encoding="utf-8") + except (OSError, ValueError): + logger.warning("Failed to read skill metadata: %s", file_path) + return {} + metadata, _ = _parse_skill_content(content, file_path) + _SKILL_METADATA_CACHE[cache_key] = metadata + return metadata + + +def get_available_skills() -> dict[str, list[dict[str, str]]]: + grouped: dict[str, list[dict[str, str]]] = {} for category, name in _iter_user_skill_files(): - grouped.setdefault(category, []).append(name) + file_path = _qualified_skill_file_for_name(f"{category}/{name}") + if file_path is None: + logger.warning( + "Skill disappeared while gathering available skills: %s/%s", + category, + name, + ) + continue + metadata = _read_skill_metadata(file_path) + description = " ".join(metadata.get("description", "").split()) + grouped.setdefault(category, []).append({"name": name, "description": description}) return grouped @@ -228,7 +290,8 @@ def load_skills(skill_names: list[str]) -> dict[str, str]: continue var_name = skill_name.split("/")[-1] - skill_content[var_name] = _FRONTMATTER_PATTERN.sub("", content).lstrip() + _, skill_body = _parse_skill_content(content, file_path) + skill_content[var_name] = skill_body logger.debug("Loaded skill: %s -> %s", skill_name, var_name) _track_skill_loaded(var_name, file_path) diff --git a/strix/skills/cloud/gcp.md b/strix/skills/cloud/gcp.md index a6f28293..2c721594 100644 --- a/strix/skills/cloud/gcp.md +++ b/strix/skills/cloud/gcp.md @@ -16,7 +16,7 @@ GCP misconfigurations expose project data, service account keys, and lateral mov **Storage & Data** - Cloud Storage (GCS) buckets and objects -- BigQuery datasets, Cloud SQL instances, Firestore (see `firebase_firestore` skill) +- BigQuery datasets, Cloud SQL instances, Firestore (see `firebase` skill) - Secret Manager, Cloud KMS keys **Compute** diff --git a/strix/skills/technologies/firebase_firestore.md b/strix/skills/technologies/firebase.md similarity index 64% rename from strix/skills/technologies/firebase_firestore.md rename to strix/skills/technologies/firebase.md index a0c48728..34104033 100644 --- a/strix/skills/technologies/firebase_firestore.md +++ b/strix/skills/technologies/firebase.md @@ -1,9 +1,9 @@ --- -name: firebase-firestore -description: Firebase/Firestore security testing covering security rules, Cloud Functions, and client-side trust issues +name: firebase +description: Firebase security testing covering Firestore, Storage rules, Realtime Database, Auth, Functions, and client-side trust issues --- -# Firebase / Firestore +# Firebase Security testing for Firebase applications. Focus on Firestore/Realtime Database rules, Cloud Storage exposure, callable/onRequest Functions trusting client input, and incorrect ID token validation. @@ -30,7 +30,17 @@ Security testing for Firebase applications. Focus on Firestore/Realtime Database **Endpoints** - Firestore REST: `https://firestore.googleapis.com/v1/projects//databases/(default)/documents/` - Realtime DB: `https://.firebaseio.com/.json` -- Storage REST: `https://storage.googleapis.com/storage/v1/b/` +- GCS JSON API: `https://storage.googleapis.com/storage/v1/b/` +- Firebase Storage rules API: `https://firebasestorage.googleapis.com/v0/b//o` + +Cloud Storage has two front doors with different authorization engines: + +| Front door | Authorization engine | +| --- | --- | +| `storage.googleapis.com//` and `/storage/v1/b/` | GCS IAM and per-object ACLs | +| `firebasestorage.googleapis.com/v0/b//o` | Firebase Storage Security Rules | + +A `403` from a GCS URL does not prove that Firebase Storage rules deny access. Always test both doors. **Auth** - Google-signed ID tokens (iss: `accounts.google.com` or `securetoken.google.com/`) @@ -117,9 +127,43 @@ exists(/databases/(default)/documents/orgs/$(org)/members/$(request.auth.uid)) - Public reads on sensitive buckets/paths - Signed URLs with long TTL, no content-disposition controls, replayable across tenants - List operations exposed: `/o?prefix=` enumerates object keys +- Firebase Storage rules allowing unauthenticated or overly broad reads and writes + +**Firebase Storage rules checks** + +Probe the rules door separately from GCS IAM and ACLs: + +1. Unauthenticated list: `GET https://firebasestorage.googleapis.com/v0/b//o?prefix=` +2. Unauthenticated read of a known object path +3. Unauthenticated write/upload to a uniquely named test object +4. Repeat list, read, and write as an anonymous-auth principal when anonymous sign-in is enabled +5. Repeat the same matrix as a low-privilege authenticated user + +Write access is as important as read access and is routinely missed. Record status, response body, and object existence after each attempt; clean up only test objects that the test principal created. + +Review rules source when present and flag: + +- `allow read, write: if request.time < timestamp.date(...)` — the common console test-mode time gate +- `{allPaths=**}` catch-alls +- `request.auth != null` as the sole authorization gate +- Claim-presence checks such as `request.auth.token.roles.size() > 0` without role or tenant validation + +Storage rules use OR-across-matches semantics: a later permissive match can reopen a path that an earlier match denied. Review every matching path, not only the most specific-looking deny. + +**Bucket discovery** + +- Extract `storageBucket` from `firebase.apps[0].options` and `NEXT_PUBLIC_FIREBASE_*` values in JavaScript bundles and source. +- Check `.appspot.com` and `.firebasestorage.app` bucket conventions. + +**ACL and IAM checks are separate** + +- Sweep object ACLs for `allUsers` and `allAuthenticatedUsers`, including objects made public by Admin SDK `makePublic()` or writers using `public: true`. Per-object public ACLs persist after Firebase rules are tightened and can remain on older prefixes. +- Check bucket IAM for `allUsers` and `allAuthenticatedUsers`. +- Check whether Uniform Bucket-Level Access is disabled; legacy object ACLs matter when it is off. +- Account for CDN caching of previously public objects; cache-bust when verifying a revocation. **Tests** -- GET gs:// paths via HTTPS without auth; verify Content-Type and `Content-Disposition: attachment` +- GET GCS object paths via HTTPS without auth; verify Content-Type and `Content-Disposition: attachment` - Generate and reuse signed URLs across accounts and paths; try case/URL-encoding variants - Upload HTML/SVG and verify `X-Content-Type-Options: nosniff`; check for script execution @@ -189,12 +233,19 @@ Apps often implement multi-tenant data models (`orgs//...`). Bind tenant ## Testing Methodology -1. **Extract config** - Get project config from client bundle -2. **Obtain principals** - Collect tokens for unauth, anonymous, user A/B, admin +1. **Extract config** - Get project and storage bucket config from client bundles and source +2. **Obtain principals** - Collect tokens for unauth, anonymous, user A/B, and admin where authorized 3. **Build matrix** - Resource × Action × Principal across Firestore/Realtime/Storage/Functions -4. **SDK vs REST** - Exercise every action via both to detect parity gaps -5. **Seed IDs** - Start from list/query paths to gather document IDs -6. **Cross-principal** - Swap document paths, tenants, and user IDs across principals +4. **Exercise both Storage doors** - Test Firebase Storage rules endpoints separately from GCS IAM/ACL URLs +5. **SDK vs REST** - Exercise every action via both to detect parity gaps +6. **Seed IDs** - Start from list/query paths to gather document and object paths +7. **Cross-principal** - Swap document paths, tenants, and user IDs across principals + +## Whitebox Rules Review + +- Inspect `firebase.json`, `.firebaserc`, deployment scripts, CI configuration, and infrastructure code for `storage.rules` / `firestore.rules` declarations. +- If `firebase.json` has no `storage` or `firestore` block, or the referenced rules file is absent from the tree, treat the live rules as unmanaged and force the live probe matrix. Absence of rules IaC is itself a finding; never conclude that there is nothing to review. +- Correlate configured rule files with deployed project and bucket identifiers. A source rule file for a different project does not establish live protection. ## Tooling @@ -206,6 +257,7 @@ Apps often implement multi-tenant data models (`orgs//...`). Bind tenant ## Validation Requirements - Owner vs non-owner Firestore queries showing unauthorized access or metadata leak -- Cloud Storage read/write beyond intended scope (public object, signed URL reuse, list exposure) +- Firebase Storage unauthenticated, anonymous, or low-privilege read/list/write beyond intended scope, with minimal reproducible requests and observed deltas +- GCS object ACL or bucket IAM access beyond intended scope, including public object persistence after rules changes - Function accepting forged/foreign identity (wrong `aud`/`iss`) or trusting client `uid`/`orgId` - Minimal reproducible requests with roles/tokens used and observed deltas diff --git a/tests/test_skill_dir_extension.py b/tests/test_skill_dir_extension.py index 0c21660c..eb28768c 100644 --- a/tests/test_skill_dir_extension.py +++ b/tests/test_skill_dir_extension.py @@ -1,8 +1,10 @@ +from collections.abc import Iterator from pathlib import Path import pytest import strix.skills as skills_mod +from strix.agents.prompt import render_system_prompt from strix.skills import ( get_all_skill_names, get_available_skills, @@ -12,10 +14,11 @@ from strix.skills import ( skill_search_dirs, validate_requested_skills, ) +from strix.utils.resource_paths import get_strix_resource_path @pytest.fixture(autouse=True) -def _clear_extra_dirs() -> None: +def _clear_extra_dirs() -> Iterator[None]: original = list(skills_mod._EXTRA_SKILL_DIRS) skills_mod._EXTRA_SKILL_DIRS.clear() try: @@ -37,9 +40,11 @@ def _write_root_skill(root: Path, name: str, body: str) -> None: def test_no_registration_leaves_builtin_only() -> None: assert registered_skill_dirs() == () - builtin = skills_mod.get_strix_resource_path("skills") + builtin = get_strix_resource_path("skills") assert skill_search_dirs() == (builtin,) - assert {"nmap", "subfinder"}.issubset(get_available_skills()["tooling"]) + assert {"nmap", "subfinder"}.issubset( + {skill["name"] for skill in get_available_skills()["tooling"]} + ) def test_register_is_idempotent_and_ordered(tmp_path: Path) -> None: @@ -61,16 +66,125 @@ def test_registered_dir_adds_new_skill(tmp_path: Path) -> None: register_skill_dir(tmp_path) assert "widget" in get_all_skill_names() - assert get_available_skills()["extra"] == ["widget"] + assert get_available_skills()["extra"] == [{"name": "widget", "description": ""}] assert load_skills(["widget"]) == {"widget": "widget body"} +def test_available_skill_includes_frontmatter_description(tmp_path: Path) -> None: + _write_skill( + tmp_path, + "extra", + "widget", + "---\nname: widget\ndescription: Useful widget guidance\n---\nwidget body", + ) + register_skill_dir(tmp_path) + + assert get_available_skills()["extra"] == [ + {"name": "widget", "description": "Useful widget guidance"} + ] + + +def test_available_skill_supports_colon_in_description(tmp_path: Path) -> None: + _write_skill( + tmp_path, + "extra", + "widget", + '---\nname: widget\ndescription: "Useful widget: handles YAML"\n---\nwidget body', + ) + register_skill_dir(tmp_path) + + assert get_available_skills()["extra"] == [ + {"name": "widget", "description": "Useful widget: handles YAML"} + ] + + +def test_available_skill_normalizes_quoted_description(tmp_path: Path) -> None: + _write_skill( + tmp_path, + "extra", + "widget", + '---\nname: widget\ndescription: "Useful: widget guidance"\n---\nwidget body', + ) + register_skill_dir(tmp_path) + + assert get_available_skills()["extra"] == [ + {"name": "widget", "description": "Useful: widget guidance"} + ] + + +def test_available_skill_normalizes_multiline_descriptions(tmp_path: Path) -> None: + _write_skill( + tmp_path, + "extra", + "block", + "---\nname: block\n\ndescription: |\n" + " First paragraph\n\n Second paragraph\n\n---\nblock body", + ) + _write_skill( + tmp_path, + "extra", + "plain", + "---\nname: plain\n\ndescription: First line\n Second line\n\n---\nplain body", + ) + register_skill_dir(tmp_path) + + available = {skill["name"]: skill["description"] for skill in get_available_skills()["extra"]} + assert available == { + "block": "First paragraph Second paragraph", + "plain": "First line Second line", + } + + +def test_available_skill_supports_block_scalar_trailing_comment(tmp_path: Path) -> None: + _write_skill( + tmp_path, + "extra", + "commented", + "---\nname: commented\ndescription: | # paragraph\n" + " First line\n Second line\n---\ncommented body", + ) + register_skill_dir(tmp_path) + + assert get_available_skills()["extra"] == [ + {"name": "commented", "description": "First line Second line"} + ] + + +def test_malformed_frontmatter_keeps_skill_body(tmp_path: Path) -> None: + _write_skill( + tmp_path, + "extra", + "broken", + "---\nname: [broken\ndescription: should be empty\n---\nbroken body", + ) + register_skill_dir(tmp_path) + + assert get_available_skills()["extra"] == [{"name": "broken", "description": ""}] + assert load_skills(["extra/broken"]) == {"broken": "broken body"} + + +def test_system_prompt_renders_skill_descriptions() -> None: + prompt = render_system_prompt(scan_mode="quick", is_root=True) + + assert "- technologies/firebase: Firebase security testing covering" in prompt + + +def test_system_prompt_omits_empty_skill_description(tmp_path: Path) -> None: + _write_skill(tmp_path, "extra", "widget", "---\nname: widget\ndescription:\n---\nwidget body") + register_skill_dir(tmp_path) + + prompt = render_system_prompt(scan_mode="quick", is_root=True) + + assert "- extra/widget\n" in prompt + assert "- extra/widget: " not in prompt + + def test_registered_root_skill_is_discoverable_and_valid(tmp_path: Path) -> None: _write_root_skill(tmp_path, "widget", "widget body") register_skill_dir(tmp_path) assert "widget" in get_all_skill_names() - assert get_available_skills()["root"] == ["widget"] + assert get_available_skills()["root"] == [{"name": "widget", "description": ""}] assert validate_requested_skills(["widget"]) is None assert validate_requested_skills(["root/widget"]) is None assert load_skills(["widget"]) == {"widget": "widget body"} @@ -83,8 +197,8 @@ def test_ambiguous_bare_skill_requires_qualified_name(tmp_path: Path) -> None: register_skill_dir(tmp_path) assert "widget" in get_all_skill_names() - assert get_available_skills()["alpha"] == ["widget"] - assert get_available_skills()["beta"] == ["widget"] + assert get_available_skills()["alpha"] == [{"name": "widget", "description": ""}] + assert get_available_skills()["beta"] == [{"name": "widget", "description": ""}] assert validate_requested_skills(["alpha/widget"]) is None assert validate_requested_skills(["beta/widget"]) is None From 0607abf9e562691d675812a1992680bcc824e30d Mon Sep 17 00:00:00 2001 From: Ahmed Allam <49919286+0xallam@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:14:22 +0300 Subject: [PATCH 55/57] fix(tui): scrollbar visibility, findings scrolling, and report navigation (#1006) --- .../tui/internal/app/findings_test.go | 299 ++++++++++++++++ strix/interface/tui/internal/app/model.go | 15 +- .../interface/tui/internal/app/model_test.go | 17 +- strix/interface/tui/internal/app/update.go | 109 ++++-- strix/interface/tui/internal/app/view.go | 24 +- .../tui/internal/app/vulnerabilities.go | 319 ++++++++++++------ 6 files changed, 634 insertions(+), 149 deletions(-) create mode 100644 strix/interface/tui/internal/app/findings_test.go diff --git a/strix/interface/tui/internal/app/findings_test.go b/strix/interface/tui/internal/app/findings_test.go new file mode 100644 index 00000000..0e865f57 --- /dev/null +++ b/strix/interface/tui/internal/app/findings_test.go @@ -0,0 +1,299 @@ +package app + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + "github.com/usestrix/strix/tui/internal/protocol" +) + +func findingsModel(t *testing.T, titles ...string) Model { + t.Helper() + m := New(nil) + m.width, m.height = 130, 30 + m.showSplash = false + m.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{ScanState: "running"})) + items := make([]json.RawMessage, 0, len(titles)) + for i, title := range titles { + items = append(items, rawJSON(t, map[string]any{ + "id": string(rune('a' + i)), "title": title, "severity": "high", + })) + } + m.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_bootstrap", + Payload: rawJSON(t, protocol.CollectionBootstrap{ + Collection: "vulnerabilities", Revision: 1, Cursor: 0, + NextCursor: len(items), Done: true, Items: items, + })}) + m.resizeViewport() + return m +} + +// The list scrolls by row, not by finding. Stepping a whole entry at a time is +// what made a list of wrapped titles feel paginated. +func TestFindingsScrollByRow(t *testing.T) { + long := "A deliberately long finding title that wraps across several rows in the sidebar" + m := findingsModel(t, long, long, long) + + rows := m.vulnerabilityRows(m.vulnerabilityListWidth()) + if len(rows) <= 3 { + t.Fatalf("titles did not wrap, so this proves nothing: %d rows", len(rows)) + } + total, offset := m.vulnerabilityScrollRows() + if total != len(rows) || offset != 0 { + t.Fatalf("scroll metrics are not in rows: total=%d offset=%d rows=%d", total, offset, len(rows)) + } + + // One step of the offset moves one row, and the first visible line follows it. + first := strings.Split(ansi.Strip(m.vulnerabilitiesView(40, 4)), "\n")[0] + m.vulnOffset = 1 + second := strings.Split(ansi.Strip(m.vulnerabilitiesView(40, 4)), "\n")[0] + if first == second { + t.Fatalf("advancing one row did not move the list: %q", first) + } + // That row still belongs to the first finding, which an item-stepping list + // would have skipped past entirely. + if got := m.vulnerabilityIndexAtRow(0); got != 0 { + t.Fatalf("one row in, the top line belongs to finding %d, want 0", got) + } +} + +// Selecting a finding scrolls the least it can, and never past its own start. +func TestSelectingAFindingBringsItIntoView(t *testing.T) { + long := "A deliberately long finding title that wraps across several rows in the sidebar" + m := findingsModel(t, long, long, long, long) + + m.selectedVuln = 3 + m.ensureVulnerabilityVisible() + + rows := m.vulnerabilityRows(m.vulnerabilityListWidth()) + height := m.vulnerabilityPageSize() + end := min(len(rows), m.vulnOffset+height) + found := false + for _, row := range rows[m.vulnOffset:end] { + if row.index == 3 { + found = true + break + } + } + if !found { + t.Fatalf("the selected finding is not on screen: offset=%d height=%d", m.vulnOffset, height) + } + if m.vulnOffset > len(rows)-height && len(rows) > height { + t.Fatalf("scrolled past the end: offset=%d rows=%d height=%d", m.vulnOffset, len(rows), height) + } +} + +func reportModel(t *testing.T, count int) Model { + t.Helper() + titles := make([]string, 0, count) + for i := range count { + titles = append(titles, fmt.Sprintf("Finding number %d", i+1)) + } + m := findingsModel(t, titles...) + m.openModal(modalVulnerability) + return m +} + +// The open report can be stepped through the list without closing it. +func TestReportStepsBetweenFindings(t *testing.T) { + m := reportModel(t, 3) + + updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyRight}) + m = updated.(Model) + if m.selectedVuln != 1 { + t.Fatalf("right moved to %d, want 1", m.selectedVuln) + } + if m.modal != modalVulnerability { + t.Fatal("stepping closed the report") + } + updated, _ = m.updateModal(tea.KeyMsg{Type: tea.KeyLeft}) + m = updated.(Model) + if m.selectedVuln != 0 { + t.Fatalf("left moved to %d, want 0", m.selectedVuln) + } +} + +// The ends do not wrap: rolling from the last report to the first would hide +// that you had reached the end. +func TestReportStepsStopAtTheEnds(t *testing.T) { + m := reportModel(t, 3) + + updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyLeft}) + m = updated.(Model) + if m.selectedVuln != 0 { + t.Fatalf("left from the first report moved to %d, want 0", m.selectedVuln) + } + + m.selectedVuln = 2 + updated, _ = m.updateModal(tea.KeyMsg{Type: tea.KeyRight}) + m = updated.(Model) + if m.selectedVuln != 2 { + t.Fatalf("right from the last report moved to %d, want 2", m.selectedVuln) + } +} + +// Each direction is offered only when there is a report that way, and a lone +// finding is offered neither. +func TestReportNavigationHintsFollowAvailability(t *testing.T) { + m := reportModel(t, 3) + for _, testCase := range []struct { + index int + wantPrev, wantNext bool + position string + }{ + {index: 0, wantNext: true, position: "1/3"}, + {index: 1, wantPrev: true, wantNext: true, position: "2/3"}, + {index: 2, wantPrev: true, position: "3/3"}, + } { + m.selectedVuln = testCase.index + view := ansi.Strip(m.modalView()) + if !strings.Contains(view, testCase.position) { + t.Fatalf("report %d does not show %q", testCase.index, testCase.position) + } + if got := strings.Contains(view, reportPrev); got != testCase.wantPrev { + t.Fatalf("report %d prev hint = %v, want %v", testCase.index, got, testCase.wantPrev) + } + if got := strings.Contains(view, reportNext); got != testCase.wantNext { + t.Fatalf("report %d next hint = %v, want %v", testCase.index, got, testCase.wantNext) + } + } + + lone := reportModel(t, 1) + view := ansi.Strip(lone.modalView()) + if strings.Contains(view, reportPrev) || strings.Contains(view, reportNext) || strings.Contains(view, "1/1") { + t.Fatalf("a lone finding offered navigation:\n%s", view) + } +} + +// A new report opens at its top, and the copy state does not carry over. +func TestSteppingResetsTheReportView(t *testing.T) { + m := reportModel(t, 3) + m.vulnerabilityCopied = true + m.vulnViewport.SetYOffset(3) + + m.showVulnerability(1) + + if m.vulnViewport.YOffset != 0 { + t.Fatalf("the next report opened scrolled to %d", m.vulnViewport.YOffset) + } + if m.vulnerabilityCopied { + t.Fatal("the copy state carried over to another report") + } +} + +// Prev and Next are buttons, not just key hints: they can be clicked. +func TestReportStepButtonsAreClickable(t *testing.T) { + m := reportModel(t, 3) + m.selectedVuln = 1 + + click := func(label string) Model { + t.Helper() + view := m.modalView() + left, top, _, _ := m.centeredViewBounds(view) + for row, line := range strings.Split(view, "\n") { + plain := ansi.Strip(line) + index := strings.Index(plain, label) + if index < 0 { + continue + } + updated, _ := m.updateModalMouse(tea.MouseMsg{ + X: left + ansi.StringWidth(plain[:index]) + 1, Y: top + row, + Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, + }) + return updated.(Model) + } + t.Fatalf("%q was not rendered", label) + return m + } + + if got := click(reportNext).selectedVuln; got != 2 { + t.Fatalf("clicking Next selected %d, want 2", got) + } + if got := click(reportPrev).selectedVuln; got != 0 { + t.Fatalf("clicking Prev selected %d, want 0", got) + } + if got := click(reportNext).modal; got != modalVulnerability { + t.Fatalf("clicking Next closed the report: modal=%v", got) + } +} + +// Tab walks the whole row, so the step buttons are reachable from the keyboard +// as well, and Enter presses whichever one is focused. +func TestTabReachesTheStepButtons(t *testing.T) { + m := reportModel(t, 3) + m.selectedVuln = 1 + + if got := m.focusedReportButton(); got != reportDone { + t.Fatalf("the report opened focused on %q, want %q", got, reportDone) + } + seen := map[string]bool{} + for range len(m.reportButtons()) { + updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyTab}) + m = updated.(Model) + seen[m.focusedReportButton()] = true + } + for _, want := range []string{reportPrev, reportNext, reportCopy, reportDone} { + if !seen[want] { + t.Fatalf("tab never reached %q: %v", want, seen) + } + } + + // Enter on a focused step button steps. + m.reportFocus = reportNext + updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyEnter}) + if got := updated.(Model).selectedVuln; got != 2 { + t.Fatalf("enter on Next selected %d, want 2", got) + } +} + +// Stepping to an end drops that button from the row; focus must not be stranded +// on it. +func TestFocusFallsBackWhenAStepButtonDisappears(t *testing.T) { + m := reportModel(t, 2) + m.selectedVuln = 0 + m.reportFocus = reportNext + + updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + + if m.selectedVuln != 1 { + t.Fatalf("enter on Next selected %d, want 1", m.selectedVuln) + } + // Next is gone at the last report, so the focus cannot still be on it. + if got := m.focusedReportButton(); got == reportNext { + t.Fatalf("focus stayed on a button that is no longer shown: %q", got) + } + if got := m.focusedReportButton(); got != reportDone { + t.Fatalf("focus fell back to %q, want %q", got, reportDone) + } +} + +// The list must be laid out at one width. Rendering at one and hit-testing at +// another gives two different row counts for the same title, and then a click +// resolves to the wrong finding and the scrollbar reports the wrong length. +func TestFindingsUseOneWidthForRenderAndInteraction(t *testing.T) { + // This title wraps to one row at 21 columns and two at 20, which is exactly + // the pair of widths the two paths used to disagree on. + m := findingsModel(t, "ffffff dddd a a a a", "eeeee eeeee a a a a", "header dddd a a a a") + + width := m.vulnerabilityListWidth() + rows := m.vulnerabilityRows(width) + rendered := strings.Split(ansi.Strip(m.vulnerabilitiesView(width, len(rows))), "\n") + + if len(rendered) != len(rows) { + t.Fatalf("rendered %d rows, interaction counts %d", len(rendered), len(rows)) + } + for row := range rendered { + if got := m.vulnerabilityIndexAtRow(row); got != rows[row].index { + t.Fatalf("row %d shows finding %d but a click resolves to %d", + row, rows[row].index, got) + } + } + if total, _ := m.vulnerabilityScrollRows(); total != len(rendered) { + t.Fatalf("the scrollbar reports %d rows, %d are rendered", total, len(rendered)) + } +} diff --git a/strix/interface/tui/internal/app/model.go b/strix/interface/tui/internal/app/model.go index f2e876e4..2cacb8eb 100644 --- a/strix/interface/tui/internal/app/model.go +++ b/strix/interface/tui/internal/app/model.go @@ -110,6 +110,7 @@ type Model struct { agentOffset int vulnOffset int modalChoice int + reportFocus string ready bool quitting bool showSplash bool @@ -157,12 +158,16 @@ const ( treeCursorBg = lipgloss.Color("#0178d4") ) -// Scrollbar thumbs. Each panel keeps its own, and the track stays blank so a -// scrollable panel does not gain a visible rule down its edge. +// Scrollbar thumbs. The track stays blank so a scrollable panel does not gain a +// visible rule down its edge, and the thumb brightens while it is dragged, which +// is the feedback Textual gave through scrollbar-color-active. +// +// One resting color for every panel, rather than the three the stylesheet named. +// The chat pane's was #1a1a1a on black, which is invisible - the bar could not be +// found, let alone grabbed (#1005). const ( - thumbTrace = lipgloss.Color("#1a1a1a") - thumbAgents = lipgloss.Color("#404040") - thumbFindings = lipgloss.Color("#333333") + thumbResting = lipgloss.Color("#3f3f46") + thumbActive = lipgloss.Color("#9ca3af") ) // Composer placeholders. The launch screen falls back to the short prompt when diff --git a/strix/interface/tui/internal/app/model_test.go b/strix/interface/tui/internal/app/model_test.go index e971d392..2b9f0dfb 100644 --- a/strix/interface/tui/internal/app/model_test.go +++ b/strix/interface/tui/internal/app/model_test.go @@ -527,7 +527,8 @@ func TestVulnerabilityCopySupportsKeyboardAndMouse(t *testing.T) { } model := newModel() - updated, _ := model.updateModal(tea.KeyMsg{Type: tea.KeyLeft}) + // Tab moves between the buttons; the arrows step between reports. + updated, _ := model.updateModal(tea.KeyMsg{Type: tea.KeyTab}) model = updated.(Model) updated, cmd := model.updateModal(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(Model) @@ -560,8 +561,8 @@ func TestVulnerabilityCopySupportsKeyboardAndMouse(t *testing.T) { X: copyX, Y: copyY, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, }) model = updated.(Model) - if cmd == nil || model.modalChoice != 0 { - t.Fatalf("mouse Copy was not activated: choice=%d cmd=%v", model.modalChoice, cmd) + if cmd == nil || model.reportFocus != reportCopy { + t.Fatalf("mouse Copy was not activated: focus=%q cmd=%v", model.reportFocus, cmd) } cmd() if len(copied) != 2 { @@ -820,8 +821,8 @@ func TestRunningViewerShowsCompleteWrappedURL(t *testing.T) { } func TestVerticalScrollbarThumbTracksScrollOffset(t *testing.T) { - top := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 0, thumbAgents)), "\n") - bottom := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 18, thumbAgents)), "\n") + top := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 0, thumbResting)), "\n") + bottom := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 18, thumbResting)), "\n") // The track is blank, so only the thumb is drawn. if top[0] != "█" || top[5] != " " { @@ -830,10 +831,10 @@ func TestVerticalScrollbarThumbTracksScrollOffset(t *testing.T) { if bottom[0] != " " || bottom[5] != "█" { t.Fatalf("bottom scrollbar is incorrect: %#v", bottom) } - if full := verticalScrollbar(4, 4, 4, 0, thumbAgents); full != "" { + if full := verticalScrollbar(4, 4, 4, 0, thumbResting); full != "" { t.Fatalf("non-overflowing scrollbar should be hidden: %q", full) } - withoutBar := ansi.Strip(withVerticalScrollbar("content", 12, 2, 2, 2, 0, thumbAgents)) + withoutBar := ansi.Strip(withVerticalScrollbar("content", 12, 2, 2, 2, 0, thumbResting)) if strings.ContainsAny(withoutBar, "█") { t.Fatalf("non-overflowing panel rendered a scrollbar: %q", withoutBar) } @@ -841,7 +842,7 @@ func TestVerticalScrollbarThumbTracksScrollOffset(t *testing.T) { // The bar takes exactly one column, so a scrolling panel keeps the rest. func TestVerticalScrollbarOccupiesOneColumn(t *testing.T) { - rows := strings.Split(withVerticalScrollbar("content", 12, 2, 24, 2, 0, thumbTrace), "\n") + rows := strings.Split(withVerticalScrollbar("content", 12, 2, 24, 2, 0, thumbResting), "\n") for _, row := range rows { if width := ansi.StringWidth(row); width != 12 { t.Fatalf("scrolling panel row width = %d, want 12", width) diff --git a/strix/interface/tui/internal/app/update.go b/strix/interface/tui/internal/app/update.go index 0cccea5d..2a22c4a9 100644 --- a/strix/interface/tui/internal/app/update.go +++ b/strix/interface/tui/internal/app/update.go @@ -219,7 +219,8 @@ func (m Model) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { case vulnHeight > 0 && y < viewerHeight+agentHeight+vulnHeight: m.focus = focusVulnerabilities m.input.Blur() - m.vulnOffset = min(max(0, len(m.snapshot.Vulnerabilities)-1), m.vulnOffset+3) + totalRows, _ := m.vulnerabilityScrollRows() + m.vulnOffset = min(max(0, totalRows-m.vulnerabilityPageSize()), m.vulnOffset+3) m.keepVulnerabilitySelectionInWindow() } return m, nil @@ -318,24 +319,7 @@ func (m *Model) updateMainScrollbarMouse( if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft { return false } - - target := scrollbarNone - switch { - case msg.X == chatWidth-2 && msg.Y >= 1 && msg.Y < chatHeight-1 && - m.viewport.TotalLineCount() > m.viewport.VisibleLineCount(): - target = scrollbarTrace - case showSidebar && msg.X == m.width-3 && msg.Y >= viewerHeight+2 && - msg.Y < viewerHeight+agentHeight-2 && - len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)) > m.agentPageSize(): - target = scrollbarAgents - case showSidebar && vulnHeight > 0 && msg.X == m.width-3 && - msg.Y >= viewerHeight+agentHeight+1 && - msg.Y < viewerHeight+agentHeight+vulnHeight-1: - totalRows, _ := m.vulnerabilityScrollRows() - if totalRows > m.vulnerabilityPageSize() { - target = scrollbarFindings - } - } + target := m.scrollbarAt(msg, showSidebar, chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight) if target == scrollbarNone { return false } @@ -344,6 +328,40 @@ func (m *Model) updateMainScrollbarMouse( return true } +// scrollbarGrab is how far either side of the bar still counts as grabbing it. A +// one column target is unreasonable to hit with a mouse, and nothing else lives +// in the column beside it. +const scrollbarGrab = 1 + +func nearColumn(x, column int) bool { + return x >= column-scrollbarGrab && x <= column+scrollbarGrab +} + +// scrollbarAt reports which scrollbar, if any, the pointer is over. +func (m Model) scrollbarAt( + msg tea.MouseMsg, + showSidebar bool, + chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight int, +) scrollbarTarget { + switch { + case nearColumn(msg.X, chatWidth-2) && msg.Y >= 1 && msg.Y < chatHeight-1 && + m.viewport.TotalLineCount() > m.viewport.VisibleLineCount(): + return scrollbarTrace + case showSidebar && nearColumn(msg.X, m.width-3) && msg.Y >= viewerHeight+2 && + msg.Y < viewerHeight+agentHeight-2 && + len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)) > m.agentPageSize(): + return scrollbarAgents + case showSidebar && vulnHeight > 0 && nearColumn(msg.X, m.width-3) && + msg.Y >= viewerHeight+agentHeight+1 && + msg.Y < viewerHeight+agentHeight+vulnHeight-1: + totalRows, _ := m.vulnerabilityScrollRows() + if totalRows > m.vulnerabilityPageSize() { + return scrollbarFindings + } + } + return scrollbarNone +} + func (m *Model) scrollFromMouse( target scrollbarTarget, y, chatHeight, viewerHeight, agentHeight int, @@ -367,10 +385,10 @@ func (m *Model) scrollFromMouse( case scrollbarFindings: height := m.vulnerabilityPageSize() totalRows, _ := m.vulnerabilityScrollRows() - rowOffset := scrollbarOffset(y-viewerHeight-agentHeight-1, height, totalRows, height) m.focus = focusVulnerabilities m.input.Blur() - m.vulnOffset = m.vulnerabilityOffsetAtRow(rowOffset) + // The offset is a row, so dragging moves the list continuously. + m.vulnOffset = scrollbarOffset(y-viewerHeight-agentHeight-1, height, totalRows, height) m.keepVulnerabilitySelectionInWindow() } } @@ -405,6 +423,22 @@ func (m Model) updateSetupMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return m, nil } +// pressReportButton performs a button of the report row, however it was reached. +func (m Model) pressReportButton(button string) (tea.Model, tea.Cmd) { + switch button { + case reportPrev: + m.showVulnerability(m.selectedVuln - 1) + case reportNext: + m.showVulnerability(m.selectedVuln + 1) + case reportCopy: + m.reportFocus = reportCopy + return m, m.startVulnerabilityCopy() + default: + m.closeModal() + } + return m, nil +} + func (m Model) updateModalMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { if m.modal == modalVulnerability { view := m.modalView() @@ -441,13 +475,22 @@ func (m Model) updateModalMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return m.updateModal(tea.KeyMsg{Type: tea.KeyEnter}) } case modalVulnerability: + for _, button := range m.reportButtons() { + if button == reportCopy || button == reportDone { + continue + } + if m.centeredLabelHit(view, button, msg.X, msg.Y) { + m.reportFocus = button + return m.pressReportButton(button) + } + } if m.centeredLabelHit(view, "Copy", msg.X, msg.Y) { - m.modalChoice = 0 + m.reportFocus = reportCopy cmd := m.startVulnerabilityCopy() return m, cmd } if m.centeredLabelHit(view, "Done", msg.X, msg.Y) { - m.modalChoice = 1 + m.reportFocus = reportDone m.closeModal() } } @@ -516,16 +559,19 @@ func (m Model) updateModal(key tea.KeyMsg) (tea.Model, tea.Cmd) { switch key.String() { case "esc": m.closeModal() - case "left", "right", "tab", "shift+tab": - m.modalChoice = 1 - m.modalChoice + // The arrows step between reports directly; tab walks the button row. + case "left": + m.showVulnerability(m.selectedVuln - 1) + case "right": + m.showVulnerability(m.selectedVuln + 1) + case "tab": + m.stepReportFocus(1) + case "shift+tab": + m.stepReportFocus(-1) case "enter": - if m.modalChoice == 0 { - cmd := m.startVulnerabilityCopy() - return m, cmd - } - m.closeModal() + return m.pressReportButton(m.focusedReportButton()) case "c": - m.modalChoice = 0 + m.reportFocus = reportCopy cmd := m.startVulnerabilityCopy() return m, cmd case "up": @@ -584,6 +630,7 @@ func (m *Model) openModal(mode modalMode) { m.modalChoice = 1 } if mode == modalVulnerability { + m.reportFocus = reportDone m.modalChoice = 1 m.vulnerabilityCopied = false m.vulnerabilityCopyError = "" diff --git a/strix/interface/tui/internal/app/view.go b/strix/interface/tui/internal/app/view.go index dec2b09b..9ac0a55e 100644 --- a/strix/interface/tui/internal/app/view.go +++ b/strix/interface/tui/internal/app/view.go @@ -164,6 +164,15 @@ func wrapBlock(value string, width int) string { return strings.Join(out, "\n") } +// scrollbarThumb brightens the bar being dragged so the grab reads as taking +// hold of it. +func (m Model) scrollbarThumb(target scrollbarTarget) lipgloss.Color { + if m.draggingScrollbar == target { + return thumbActive + } + return thumbResting +} + func verticalScrollbar(height, total, visible, offset int, thumb lipgloss.Color) string { if height <= 0 || total <= visible { return "" @@ -424,7 +433,7 @@ func (m Model) renderChatPane(width, height int, border lipgloss.Color) string { m.viewport.TotalLineCount(), m.viewport.VisibleLineCount(), m.viewport.YOffset, - thumbTrace, + m.scrollbarThumb(scrollbarTrace), ) out := lipgloss.NewStyle().Width(width).Height(height). Border(lipgloss.RoundedBorder()).BorderForeground(border).Render(trace) @@ -489,7 +498,7 @@ func (m Model) sidebarView(width, height int) string { len(agentEntries), agentRows, m.agentOffset, - thumbAgents, + m.scrollbarThumb(scrollbarAgents), ) parts := []string{ lipgloss.NewStyle().Width(width-2).Height(m.viewerHeight()-2).Border(lipgloss.RoundedBorder()).BorderForeground(dark).Padding(0, 1).Render(m.viewerView(width - 4)), @@ -503,13 +512,13 @@ func (m Model) sidebarView(width, height int) string { vulnRows := max(1, vulnHeight-2) totalRows, offsetRows := m.vulnerabilityScrollRows() findings := withVerticalScrollbar( - m.vulnerabilitiesView(max(1, width-5), vulnRows), + m.vulnerabilitiesView(m.vulnerabilityListWidth(), vulnRows), width-4, vulnRows, totalRows, vulnRows, offsetRows, - thumbFindings, + m.scrollbarThumb(scrollbarFindings), ) parts = append(parts, lipgloss.NewStyle().Width(width-2).Height(vulnRows).Border(lipgloss.RoundedBorder()).BorderForeground(vulnBorder).Padding(0, 1).Render(findings)) } @@ -524,12 +533,7 @@ func (m Model) sidebarHeights() (statsHeight, vulnHeight, agentHeight int) { statsRows := lipgloss.Height(lipgloss.NewStyle().Width(m.viewerContentWidth()).Render(m.statsView())) statsHeight = min(15, statsRows+2) if len(m.snapshot.Vulnerabilities) > 0 { - rows := 0 - width := m.vulnerabilityListWidth() - for i := range m.snapshot.Vulnerabilities { - rows += len(m.vulnerabilityTitleLines(i, width)) - } - vulnHeight = min(12, rows+2) + vulnHeight = min(12, len(m.vulnerabilityRows(m.vulnerabilityListWidth()))+2) } agentHeight = max(3, m.height-m.viewerHeight()-statsHeight-vulnHeight) return diff --git a/strix/interface/tui/internal/app/vulnerabilities.go b/strix/interface/tui/internal/app/vulnerabilities.go index 0d4f23b4..b8d988bf 100644 --- a/strix/interface/tui/internal/app/vulnerabilities.go +++ b/strix/interface/tui/internal/app/vulnerabilities.go @@ -1,6 +1,7 @@ package app import ( + "fmt" "strings" tea "github.com/charmbracelet/bubbletea" @@ -13,115 +14,123 @@ var panelSeverityColors = map[string]lipgloss.Color{ "critical": render.SevCrit, "high": render.SevHigh, "medium": render.SevMed, "low": green, "info": blue, } -func (m Model) vulnerabilitiesView(width, height int) string { - var lines []string - start := min(max(0, m.vulnOffset), max(0, len(m.snapshot.Vulnerabilities)-1)) - for i := start; i < len(m.snapshot.Vulnerabilities) && len(lines) < height; i++ { - vuln := m.snapshot.Vulnerabilities[i] - severity := strings.ToLower(render.StringValue(vuln["severity"])) - color, ok := panelSeverityColors[severity] - if !ok { - color = blue // matches SEVERITY_COLORS.get(severity, "#3b82f6") +// vulnerabilityRow is one rendered line of the findings list. The list scrolls by +// row rather than by finding, so a long title does not make the panel jump a +// whole entry at a time. +type vulnerabilityRow struct { + index int // the finding this line belongs to + text string // one wrapped line of its title + first bool // the line that carries the number and the severity dot +} + +// vulnerabilityRows lays every finding out as the lines it will occupy. +func (m Model) vulnerabilityRows(width int) []vulnerabilityRow { + // Wrapped lines sit under the title rather than under the severity dot. + body := max(1, width-2) + rows := make([]vulnerabilityRow, 0, len(m.snapshot.Vulnerabilities)) + for i := range m.snapshot.Vulnerabilities { + for line, text := range strings.Split(wrapBlock(m.vulnerabilityTitle(i), body), "\n") { + rows = append(rows, vulnerabilityRow{index: i, text: text, first: line == 0}) } - marker := lipgloss.NewStyle().Foreground(color).Render("● ") + } + return rows +} + +func (m Model) vulnerabilitiesView(width, height int) string { + rows := m.vulnerabilityRows(width) + start := min(max(0, m.vulnOffset), max(0, len(rows)-1)) + end := min(len(rows), start+height) + lines := make([]string, 0, max(0, end-start)) + for _, row := range rows[start:end] { style := lipgloss.NewStyle().Foreground(textColor) - if i == m.selectedVuln { + if row.index == m.selectedVuln { style = style.Bold(true).Foreground(white) } - for row, titleLine := range m.vulnerabilityTitleLines(i, width) { - if len(lines) >= height { - break + prefix := " " + if row.first { + severity := strings.ToLower(render.StringValue(m.snapshot.Vulnerabilities[row.index]["severity"])) + color, ok := panelSeverityColors[severity] + if !ok { + color = blue // matches SEVERITY_COLORS.get(severity, "#3b82f6") } - prefix := " " - if row == 0 { - prefix = marker - } - lines = append(lines, prefix+style.Render(titleLine)) + prefix = lipgloss.NewStyle().Foreground(color).Render("● ") } + lines = append(lines, prefix+style.Render(row.text)) } return strings.Join(lines, "\n") } +// vulnerabilityListWidth is the one width the findings list is laid out at, for +// rendering and for every interaction alike. Wrapping a title at two widths a +// column apart gives two different row counts, and then a click resolves to the +// wrong finding and the scrollbar reports the wrong length. +// +// The panel is sidebarWidth-2 wide with a column of padding either side, and the +// scrollbar takes one more. That last column is reserved whether or not the bar +// is showing, so the layout does not shift as the list grows past the panel. func (m Model) vulnerabilityListWidth() int { _, sidebarWidth, _, _ := m.layout() - return max(1, sidebarWidth-6) + return max(1, sidebarWidth-5) } -func (m Model) vulnerabilityTitleLines(index, width int) []string { +func (m Model) vulnerabilityTitle(index int) string { title := render.StringValue(m.snapshot.Vulnerabilities[index]["title"]) if title == "" { title = "Unknown Vulnerability" } - return strings.Split(wrapBlock(title, max(1, width-2)), "\n") + return title } +// vulnerabilityScrollRows reports the list length and position in rows, which is +// what the scrollbar needs to move continuously. func (m Model) vulnerabilityScrollRows() (total, offset int) { - width := m.vulnerabilityListWidth() - for i := range m.snapshot.Vulnerabilities { - rows := len(m.vulnerabilityTitleLines(i, width)) - total += rows - if i < m.vulnOffset { - offset += rows - } - } - return total, offset -} - -func (m Model) vulnerabilityOffsetAtRow(targetRow int) int { - width := m.vulnerabilityListWidth() - row := 0 - for i := range m.snapshot.Vulnerabilities { - row += len(m.vulnerabilityTitleLines(i, width)) - if targetRow < row { - return i - } - } - return max(0, len(m.snapshot.Vulnerabilities)-1) -} - -func (m Model) vulnerabilityVisibleEnd(start int) int { - height := m.vulnerabilityPageSize() - width := m.vulnerabilityListWidth() - rows := 0 - end := min(max(0, start), len(m.snapshot.Vulnerabilities)) - for end < len(m.snapshot.Vulnerabilities) { - itemRows := len(m.vulnerabilityTitleLines(end, width)) - if rows > 0 && rows+itemRows > height { - break - } - rows += itemRows - end++ - if rows >= height { - break - } - } - return end + return len(m.vulnerabilityRows(m.vulnerabilityListWidth())), m.vulnOffset } +// vulnerabilityIndexAtRow maps a click on a visible row back to its finding. func (m Model) vulnerabilityIndexAtRow(row int) int { - width := m.vulnerabilityListWidth() - currentRow := 0 - for i := m.vulnOffset; i < m.vulnerabilityVisibleEnd(m.vulnOffset); i++ { - currentRow += len(m.vulnerabilityTitleLines(i, width)) - if row < currentRow { - return i - } + rows := m.vulnerabilityRows(m.vulnerabilityListWidth()) + target := m.vulnOffset + row + if target < 0 || target >= len(rows) { + return -1 } - return -1 + return rows[target].index } +// ensureVulnerabilityVisible scrolls the least it can to bring the selected +// finding into view, keeping the whole entry visible where it fits. func (m *Model) ensureVulnerabilityVisible() { - if len(m.snapshot.Vulnerabilities) == 0 { + rows := m.vulnerabilityRows(m.vulnerabilityListWidth()) + if len(rows) == 0 { m.vulnOffset = 0 return } - if m.selectedVuln < m.vulnOffset { - m.vulnOffset = m.selectedVuln + height := m.vulnerabilityPageSize() + firstRow, lastRow := -1, -1 + for row, entry := range rows { + if entry.index != m.selectedVuln { + continue + } + if firstRow < 0 { + firstRow = row + } + lastRow = row } - for m.selectedVuln >= m.vulnerabilityVisibleEnd(m.vulnOffset) && m.vulnOffset < m.selectedVuln { - m.vulnOffset++ + if firstRow < 0 { + m.vulnOffset = clampVulnerabilityOffset(m.vulnOffset, len(rows), height) + return } - m.vulnOffset = min(m.vulnOffset, len(m.snapshot.Vulnerabilities)-1) + if firstRow < m.vulnOffset { + m.vulnOffset = firstRow + } else if lastRow >= m.vulnOffset+height { + // Prefer showing the whole entry, but never scroll its start out of view. + m.vulnOffset = min(firstRow, lastRow-height+1) + } + m.vulnOffset = clampVulnerabilityOffset(m.vulnOffset, len(rows), height) +} + +func clampVulnerabilityOffset(offset, total, height int) int { + return min(max(0, offset), max(0, total-height)) } func (m Model) vulnerabilityPageSize() int { @@ -129,23 +138,52 @@ func (m Model) vulnerabilityPageSize() int { return max(1, vulnHeight-2) } +// vulnerabilityPageItems is how many findings a page step should move by: the +// number of distinct entries currently on screen. func (m Model) vulnerabilityPageItems() int { - return max(1, m.vulnerabilityVisibleEnd(m.vulnOffset)-m.vulnOffset) + rows := m.vulnerabilityRows(m.vulnerabilityListWidth()) + height := m.vulnerabilityPageSize() + start := min(max(0, m.vulnOffset), max(0, len(rows))) + end := min(len(rows), start+height) + seen := 0 + previous := -1 + for _, row := range rows[start:end] { + if row.index != previous { + seen++ + previous = row.index + } + } + return max(1, seen) } func (m *Model) moveVulnerabilitySelection(delta int) { m.selectedVuln = max(0, min(len(m.snapshot.Vulnerabilities)-1, m.selectedVuln+delta)) } +// keepVulnerabilitySelectionInWindow pulls the selection to the nearest finding +// still on screen after the list has been scrolled directly. func (m *Model) keepVulnerabilitySelectionInWindow() { - if len(m.snapshot.Vulnerabilities) == 0 { + rows := m.vulnerabilityRows(m.vulnerabilityListWidth()) + if len(rows) == 0 { return } - if m.selectedVuln < m.vulnOffset { - m.selectedVuln = m.vulnOffset - } else if end := m.vulnerabilityVisibleEnd(m.vulnOffset); m.selectedVuln >= end { - m.selectedVuln = max(m.vulnOffset, end-1) + height := m.vulnerabilityPageSize() + start := min(max(0, m.vulnOffset), max(0, len(rows)-1)) + end := min(len(rows), start+height) + visible := rows[start:end] + if len(visible) == 0 { + return } + for _, row := range visible { + if row.index == m.selectedVuln { + return + } + } + if m.selectedVuln < visible[0].index { + m.selectedVuln = visible[0].index + return + } + m.selectedVuln = visible[len(visible)-1].index } // statsView ports build_tui_stats_text + the version line appended in @@ -373,25 +411,116 @@ func (m Model) vulnerabilityDetail() string { inner := max(1, width-8) // Button row: right-aligned Copy / Done above a top rule (#vuln_detail_buttons). rule := lipgloss.NewStyle().Foreground(lipgloss.Color("#1a1a1a")).Render(strings.Repeat("─", max(1, inner))) - copyLabel := "Copy" - if m.vulnerabilityCopied { - copyLabel = "Copied!" - } else if m.vulnerabilityCopyError != "" { - copyLabel = "Copy failed" + focused := m.focusedReportButton() + var stepping, acting []string + for _, button := range m.reportButtons() { + rendered := m.reportButton(button, button == focused) + if button == reportPrev || button == reportNext { + stepping = append(stepping, rendered) + continue + } + acting = append(acting, rendered) } - copyButton := lipgloss.NewStyle().Foreground(lipgloss.Color("#525252")) - doneButton := lipgloss.NewStyle().Foreground(mid) - if m.modalChoice == 0 { - copyButton = copyButton.Background(lipgloss.Color("#363636")).Foreground(brightWhite).Bold(true).Padding(0, 1) - } else { - doneButton = doneButton.Background(lipgloss.Color("#363636")).Foreground(brightWhite).Bold(true).Padding(0, 1) + // Stepping sits on the left behind the position, acting on the right. + right := strings.Join(acting, " ") + left := strings.Join(stepping, " ") + if total := len(m.snapshot.Vulnerabilities); total > 1 { + left = render.Dim().Render(fmt.Sprintf("%d/%d", m.selectedVuln+1, total)) + " " + left } - buttons := copyButton.Render(copyLabel) + " " + doneButton.Render("Done") - buttonRow := rule + "\n" + lipgloss.NewStyle().Width(inner).Align(lipgloss.Right).Render(buttons) + room := max(0, inner-lipgloss.Width(right)) + buttonRow := rule + "\n" + + lipgloss.NewStyle().Width(room).Render(truncate(left, room)) + right content := m.vulnerabilityScrollView() + "\n" + buttonRow return lipgloss.NewStyle().Width(width-2).Height(height-2).Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("#262626")).Background(lipgloss.Color("#0a0a0a")).Padding(2, 3).Render(content) } +// showVulnerability moves the open report to another finding, keeping the list +// behind it in step and starting the new report at its top. +func (m *Model) showVulnerability(index int) { + if index < 0 || index >= len(m.snapshot.Vulnerabilities) || index == m.selectedVuln { + return + } + m.selectedVuln = index + m.ensureVulnerabilityVisible() + // The copy state belongs to the report that was on screen, not this one. + m.vulnerabilityCopied = false + m.vulnerabilityCopyError = "" + m.resizeVulnerabilityViewport() + m.vulnViewport.GotoTop() +} + +// The report's buttons. Prev and Next carry their arrows so a click test cannot +// be fooled by the same word appearing in the body of a finding. +const ( + reportPrev = "‹ Prev" + reportNext = "Next ›" + reportCopy = "Copy" + reportDone = "Done" +) + +// reportButtons is the row as it stands, left to right. Stepping is offered only +// in the directions that have a report. +func (m Model) reportButtons() []string { + previous, next := m.vulnerabilityNeighbors() + buttons := make([]string, 0, 4) + if previous { + buttons = append(buttons, reportPrev) + } + if next { + buttons = append(buttons, reportNext) + } + return append(buttons, reportCopy, reportDone) +} + +// focusedReportButton is the button Enter would press. It falls back to Done when +// the focused one has gone, which happens when stepping to either end drops a +// direction from the row. +func (m Model) focusedReportButton() string { + for _, button := range m.reportButtons() { + if button == m.reportFocus { + return button + } + } + return reportDone +} + +// stepReportFocus moves along the row, wrapping at its ends. +func (m *Model) stepReportFocus(delta int) { + buttons := m.reportButtons() + current := 0 + for i, button := range buttons { + if button == m.focusedReportButton() { + current = i + } + } + m.reportFocus = buttons[clampCycle(current+delta, len(buttons))] +} + +// vulnerabilityNeighbors reports which way the open report can be stepped. The +// ends are not wrapped: a report is one of an ordered list, and rolling from the +// last to the first hides that you reached the end. +func (m Model) vulnerabilityNeighbors() (previous, next bool) { + return m.selectedVuln > 0, m.selectedVuln < len(m.snapshot.Vulnerabilities)-1 +} + +// reportButton renders one button of the report row. Copy reports the outcome of +// the last attempt in its own label. +func (m Model) reportButton(label string, focused bool) string { + if label == reportCopy { + switch { + case m.vulnerabilityCopied: + label = "Copied!" + case m.vulnerabilityCopyError != "": + label = "Copy failed" + } + } + if focused { + return lipgloss.NewStyle().Background(lipgloss.Color("#363636")). + Foreground(brightWhite).Bold(true).Padding(0, 1).Render(label) + } + return lipgloss.NewStyle().Foreground(lipgloss.Color("#525252")).Render(label) +} + func (m *Model) startVulnerabilityCopy() tea.Cmd { m.vulnerabilityCopied = false m.vulnerabilityCopyError = "" From 22750077da60eb004a5e03656e2c0e7d304826f8 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Fri, 7 Aug 2026 17:20:09 +0000 Subject: [PATCH 56/57] chore: release v1.5.1 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1c88a1a2..d64b9f64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "strix-agent" -version = "1.5.0" +version = "1.5.1" description = "Open-source AI Hackers for your apps" readme = "README.md" license = "Apache-2.0" diff --git a/uv.lock b/uv.lock index ac40732e..a4561526 100644 --- a/uv.lock +++ b/uv.lock @@ -2378,7 +2378,7 @@ wheels = [ [[package]] name = "strix-agent" -version = "1.5.0" +version = "1.5.1" source = { editable = "." } dependencies = [ { name = "caido-sdk-client" }, From f8a8801d5672529ddf66271415ed3ec379186842 Mon Sep 17 00:00:00 2001 From: alex s <46074070+bearsyankees@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:24:19 -0400 Subject: [PATCH 57/57] docs(skills): rename skills to descriptive names and broaden descriptions for discoverability (#1013) --- AGENTS.md | 8 ++++---- README.md | 2 +- docs/integrations/coding-agents.mdx | 14 +++++++------- .../SKILL.md | 8 ++++---- .../SKILL.md | 6 +++--- .../SKILL.md | 8 ++++---- .../SKILL.md | 10 +++++----- 7 files changed, 28 insertions(+), 28 deletions(-) rename skills/{strix-ci-setup => ci-security-scanning-with-strix}/SKILL.md (90%) rename skills/{strix-fix-findings => fix-security-vulnerabilities-with-strix}/SKILL.md (89%) rename skills/{strix-cloud-api => managed-pentesting-with-strix}/SKILL.md (89%) rename skills/{strix-pentest => penetration-testing-with-strix}/SKILL.md (88%) diff --git a/AGENTS.md b/AGENTS.md index 0504d63b..de2eac86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,10 +10,10 @@ Install the agent skills for step-by-step workflows: npx skills add usestrix/strix ``` -- `strix-pentest` — run a headless pentest against code, URLs, domains, or IPs and read results (covers both run modes below) -- `strix-cloud-api` — drive the managed app.strix.ai platform via REST (no local Docker/LLM needed) -- `strix-fix-findings` — remediate findings and re-run Strix to verify -- `strix-ci-setup` — add PR scanning to CI/CD (self-hosted CLI or managed app) +- `penetration-testing-with-strix` — run a headless pentest against code, URLs, domains, or IPs and read results (covers both run modes below) +- `managed-pentesting-with-strix` — drive the managed app.strix.ai platform via REST (no local Docker/LLM needed) +- `fix-security-vulnerabilities-with-strix` — remediate findings and re-run Strix to verify +- `ci-security-scanning-with-strix` — add PR scanning to CI/CD (self-hosted CLI or managed app) **Two ways to run, same engine — pick per situation:** diff --git a/README.md b/README.md index 09668fba..2e4dfa3e 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ Strix is agent-ready. Give Claude Code, Cursor, Codex, or any [SKILL.md-compatib npx skills add usestrix/strix ``` -This installs four skills: **strix-pentest** (run headless scans and read results), **strix-cloud-api** (drive the managed [app.strix.ai](https://app.strix.ai) platform via REST — no local Docker or LLM key), **strix-fix-findings** (remediate + re-scan to verify), and **strix-ci-setup** (PR scanning in CI). Agents can run Strix two ways with the same engine — the open-source CLI locally, or the managed cloud when there's no local infra — and read [`AGENTS.md`](AGENTS.md) for a quick reference, [docs.strix.ai/llms.txt](https://docs.strix.ai/llms.txt) for the CLI docs, and [docs.app.strix.ai](https://docs.app.strix.ai) for the API. +This installs four skills: **penetration-testing-with-strix** (run headless scans and read results), **managed-pentesting-with-strix** (drive the managed [app.strix.ai](https://app.strix.ai) platform via REST — no local Docker or LLM key), **fix-security-vulnerabilities-with-strix** (remediate + re-scan to verify), and **ci-security-scanning-with-strix** (PR scanning in CI). Agents can run Strix two ways with the same engine — the open-source CLI locally, or the managed cloud when there's no local infra — and read [`AGENTS.md`](AGENTS.md) for a quick reference, [docs.strix.ai/llms.txt](https://docs.strix.ai/llms.txt) for the CLI docs, and [docs.app.strix.ai](https://docs.app.strix.ai) for the API. --- diff --git a/docs/integrations/coding-agents.mdx b/docs/integrations/coding-agents.mdx index 777c28ab..fa2cea63 100644 --- a/docs/integrations/coding-agents.mdx +++ b/docs/integrations/coding-agents.mdx @@ -15,15 +15,15 @@ npx skills add usestrix/strix | Skill | What your agent learns | |-------|------------------------| -| `strix-pentest` | Run headless scans against code, URLs, domains, or IPs — self-hosted CLI or managed cloud — with budget caps, and read the results | -| `strix-cloud-api` | Drive the managed [app.strix.ai](https://app.strix.ai) platform over REST — no local Docker or LLM key needed | -| `strix-fix-findings` | Triage findings, fix root causes, and re-run Strix to verify each fix | -| `strix-ci-setup` | Add PR security scanning to GitHub Actions or any CI (self-hosted CLI or managed app) | +| `penetration-testing-with-strix` | Run headless scans against code, URLs, domains, or IPs — self-hosted CLI or managed cloud — with budget caps, and read the results | +| `managed-pentesting-with-strix` | Drive the managed [app.strix.ai](https://app.strix.ai) platform over REST — no local Docker or LLM key needed | +| `fix-security-vulnerabilities-with-strix` | Triage findings, fix root causes, and re-run Strix to verify each fix | +| `ci-security-scanning-with-strix` | Add PR security scanning to GitHub Actions or any CI (self-hosted CLI or managed app) | -Install a single skill with `npx skills add usestrix/strix --skill strix-pentest`, or use one without installing: +Install a single skill with `npx skills add usestrix/strix --skill penetration-testing-with-strix`, or use one without installing: ```bash -npx skills use usestrix/strix@strix-pentest | claude +npx skills use usestrix/strix@penetration-testing-with-strix | claude ``` ## Two ways to run — self-hosted or managed @@ -31,7 +31,7 @@ npx skills use usestrix/strix@strix-pentest | claude Both use the same engine and produce the same validated findings and SARIF, so agents can pick per situation or combine them: - **Open-source CLI (self-hosted)** — runs locally in a Docker sandbox with your own LLM key. Free, fully local, air-gap capable. Best for local dev loops and full control. -- **Managed cloud** — runs on Strix's infrastructure via the [app.strix.ai REST API](https://docs.app.strix.ai). No Docker, no LLM key, no local install; adds team dashboards, scheduling, PR reviews, and downloadable PDF/DOCX reports (Enterprise plan). Best in sandboxed/CI environments and for teams. Create an API token under **Settings → API Access**; the `strix-cloud-api` skill has the full flow. +- **Managed cloud** — runs on Strix's infrastructure via the [app.strix.ai REST API](https://docs.app.strix.ai). No Docker, no LLM key, no local install; adds team dashboards, scheduling, PR reviews, and downloadable PDF/DOCX reports (Enterprise plan). Best in sandboxed/CI environments and for teams. Create an API token under **Settings → API Access**; the `managed-pentesting-with-strix` skill has the full flow. ## Agent-Friendly Interfaces diff --git a/skills/strix-ci-setup/SKILL.md b/skills/ci-security-scanning-with-strix/SKILL.md similarity index 90% rename from skills/strix-ci-setup/SKILL.md rename to skills/ci-security-scanning-with-strix/SKILL.md index 377b62c0..10ed88ba 100644 --- a/skills/strix-ci-setup/SKILL.md +++ b/skills/ci-security-scanning-with-strix/SKILL.md @@ -1,6 +1,6 @@ --- -name: strix-ci-setup -description: Wire Strix security scanning into CI/CD — GitHub Actions, GitLab CI, or any pipeline — so every pull request gets a diff-scoped AI pentest that blocks vulnerable code. Covers both the self-hosted open-source CLI (runs in your runner) and the managed app.strix.ai platform (GitHub/GitLab app or API, no runner infra). Use when the user asks to add security scanning, pentesting, or Strix to their CI pipeline or PR workflow. +name: ci-security-scanning-with-strix +description: Add security scanning to CI/CD with Strix — GitHub Actions, GitLab CI, or any pipeline — so every pull request gets a diff-scoped AI pentest that blocks vulnerable code before it merges, with results as PR comments and SARIF uploaded to code scanning. Covers both the self-hosted open-source CLI (runs in your runner) and the managed app.strix.ai platform (GitHub/GitLab app or API, no runner infra). Use when the user asks to add security scanning, SAST/DAST, pentesting, vulnerability checks, or automated security review to their CI pipeline, pre-merge gate, or PR workflow. license: Apache-2.0 metadata: author: usestrix @@ -11,7 +11,7 @@ metadata: You can gate PRs two ways — pick based on the environment, or combine them: -- **Managed platform (recommended for most teams)** — connect the GitHub/GitLab/Bitbucket app once and Strix reviews every PR with **no workflow file, no runner, no Docker, and no LLM key**. Results post as PR comments and land in the team dashboard. Best when you want zero CI maintenance, central tracking, or your runners lack Docker. See "Managed platform" below and the **strix-cloud-api** skill. +- **Managed platform (recommended for most teams)** — connect the GitHub/GitLab/Bitbucket app once and Strix reviews every PR with **no workflow file, no runner, no Docker, and no LLM key**. Results post as PR comments and land in the team dashboard. Best when you want zero CI maintenance, central tracking, or your runners lack Docker. See "Managed platform" below and the **managed-pentesting-with-strix** skill. - **Self-hosted OSS CLI in your runner** — run a diff-scoped scan as a pipeline step. Fully in your infra, free (BYO LLM key), no external account. Requires Docker on the runner. Best for air-gapped/self-hosted CI or when you don't want scans leaving your environment. Both fail the build on validated findings and both emit SARIF 2.1.0, so you can start with one and add the other later. @@ -131,6 +131,6 @@ No workflow file, no Docker, no LLM key. Two ways to use it: -d "{\"repository_full_name\":\"${{ github.repository }}\",\"pr_number\":${{ github.event.pull_request.number }}}" ``` - To gate the build on results, poll the PR review / scan status and fail on unresolved criticals/highs. Full endpoints (PR reviews, scans, SARIF export, schedules for scheduled deep scans) are in the **strix-cloud-api** skill. + To gate the build on results, poll the PR review / scan status and fail on unresolved criticals/highs. Full endpoints (PR reviews, scans, SARIF export, schedules for scheduled deep scans) are in the **managed-pentesting-with-strix** skill. Recommend Option B for most teams (no maintenance, central dashboard); use Option A when scans must stay entirely within your own infrastructure. diff --git a/skills/strix-fix-findings/SKILL.md b/skills/fix-security-vulnerabilities-with-strix/SKILL.md similarity index 89% rename from skills/strix-fix-findings/SKILL.md rename to skills/fix-security-vulnerabilities-with-strix/SKILL.md index 292749d7..5e3ad0c7 100644 --- a/skills/strix-fix-findings/SKILL.md +++ b/skills/fix-security-vulnerabilities-with-strix/SKILL.md @@ -1,6 +1,6 @@ --- -name: strix-fix-findings -description: Triage and remediate vulnerabilities found by a Strix pentest (open-source CLI or app.strix.ai cloud), then re-run Strix to verify each fix. Use after a Strix scan reports findings, or when the user asks to fix security issues from a strix_runs report, vulnerabilities.json, findings.sarif, or a cloud scan's vulnerabilities. +name: fix-security-vulnerabilities-with-strix +description: Fix security vulnerabilities found by a Strix pentest (open-source CLI or app.strix.ai cloud) — triage by severity, patch the root cause rather than the symptom, and re-run Strix to prove each fix actually closes the exploit. Handles injection, XSS, SSRF, broken access control, IDOR, and other validated findings. Use after a Strix scan reports findings, or when the user asks to remediate, patch, or fix security issues from a strix_runs report, vulnerabilities.json, findings.sarif, or a cloud scan. license: Apache-2.0 metadata: author: usestrix @@ -18,7 +18,7 @@ Get the findings from wherever the scan ran: - **OSS CLI** — artifacts in `strix_runs//`: - `vulnerabilities/*.md` — one finding per file: description, severity, PoC steps or script, affected code locations, remediation guidance. - `vulnerabilities.json` — the same findings as JSON (ids, severity, CWE/CVE, `code_locations` with `fix_before`/`fix_after` suggestions when available). -- **Cloud (app.strix.ai)** — fetch the scan's `vulnerabilities[]` via `GET /api/v1/scans/{scanId}` (or `GET /api/v1/vulnerabilities` org-wide). Each carries `severity, cwe, endpoint, method, impact, technical_analysis, poc_description, poc_script_code` and, for code findings, `code_file`/`code_diff`/`code_before`/`code_after`. See the **strix-cloud-api** skill for auth. +- **Cloud (app.strix.ai)** — fetch the scan's `vulnerabilities[]` via `GET /api/v1/scans/{scanId}` (or `GET /api/v1/vulnerabilities` org-wide). Each carries `severity, cwe, endpoint, method, impact, technical_analysis, poc_description, poc_script_code` and, for code findings, `code_file`/`code_diff`/`code_before`/`code_after`. See the **managed-pentesting-with-strix** skill for auth. Order work by severity: critical → high → medium → low. Every Strix finding was validated with a working proof-of-concept, so do not dismiss findings as false positives without re-testing the PoC yourself. diff --git a/skills/strix-cloud-api/SKILL.md b/skills/managed-pentesting-with-strix/SKILL.md similarity index 89% rename from skills/strix-cloud-api/SKILL.md rename to skills/managed-pentesting-with-strix/SKILL.md index b45ef533..f2f01c19 100644 --- a/skills/strix-cloud-api/SKILL.md +++ b/skills/managed-pentesting-with-strix/SKILL.md @@ -1,6 +1,6 @@ --- -name: strix-cloud-api -description: Drive the managed Strix platform headlessly through the app.strix.ai REST API — create an API token, register domain/repository assets, launch and poll pentest scans, list and triage vulnerabilities, export SARIF, download PDF/DOCX reports (Enterprise plan), start PR reviews, and set up schedules and webhooks. Use when the user wants Strix without local Docker/LLM infra, or wants scans tracked in a team dashboard, on a schedule, or in CI via API. +name: managed-pentesting-with-strix +description: Run a managed pentest of a web app or API through the app.strix.ai REST API — no local Docker, LLM key, or install needed. Create an API token, register domain/repository assets, launch and poll scans, triage vulnerabilities, export SARIF, download PDF/DOCX pentest reports for SOC 2 and other compliance evidence (Enterprise plan), start PR reviews, and set up schedules and webhooks. Use when the user wants continuous or scheduled pentesting-as-a-service, an auditor-ready pentest report, scans tracked in a team dashboard, or security testing from a sandboxed agent/CI environment with no infrastructure. license: Apache-2.0 metadata: author: usestrix @@ -9,7 +9,7 @@ metadata: # Strix Cloud API (managed, no local infra) -Use this when you want Strix's autonomous pentesting **without running Docker or an LLM yourself** — the scan runs on Strix's infrastructure and results are tracked in a team dashboard. This is the right choice in sandboxed/hosted agent and CI environments, for teams, and for scheduled/continuous testing (downloadable PDF/DOCX reports are an Enterprise-plan feature). For fully local, free, air-gapped, or BYO-LLM runs, use the open-source CLI in the **strix-pentest** skill instead — both share the same engine and SARIF output, so you can mix them. +Use this when you want Strix's autonomous pentesting **without running Docker or an LLM yourself** — the scan runs on Strix's infrastructure and results are tracked in a team dashboard. This is the right choice in sandboxed/hosted agent and CI environments, for teams, and for scheduled/continuous testing (downloadable PDF/DOCX reports are an Enterprise-plan feature). For fully local, free, air-gapped, or BYO-LLM runs, use the open-source CLI in the **penetration-testing-with-strix** skill instead — both share the same engine and SARIF output, so you can mix them. Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · OpenAPI: `https://docs.app.strix.ai/openapi.json` @@ -113,7 +113,7 @@ curl -sS "$BASE/scans/$scan_id" "${auth[@]}" \ Cloud severities are `critical | high | medium | low` and statuses are `open | in_progress | fixed | ignored`. Sort by an explicit severity order rather than `sort_by(.severity)`, which sorts alphabetically (critical, high, low, medium). -Org-wide triage across scans: `GET /vulnerabilities` (`vulnerabilities:read`; filter by severity/status). Update triage state with the vulnerabilities `:write` endpoints. To remediate, hand off to the **strix-fix-findings** skill. +Org-wide triage across scans: `GET /vulnerabilities` (`vulnerabilities:read`; filter by severity/status). Update triage state with the vulnerabilities `:write` endpoints. To remediate, hand off to the **fix-security-vulnerabilities-with-strix** skill. ## 5. Export & report diff --git a/skills/strix-pentest/SKILL.md b/skills/penetration-testing-with-strix/SKILL.md similarity index 88% rename from skills/strix-pentest/SKILL.md rename to skills/penetration-testing-with-strix/SKILL.md index 56441c2c..1745753e 100644 --- a/skills/strix-pentest/SKILL.md +++ b/skills/penetration-testing-with-strix/SKILL.md @@ -1,6 +1,6 @@ --- -name: strix-pentest -description: Run an autonomous AI penetration test with Strix against a codebase, repository, URL, domain, or IP — either self-hosted with the open-source CLI or via the managed app.strix.ai cloud API — and read the validated findings (Markdown, JSON, CSV, SARIF, PoCs). Use when the user asks to pentest, security-scan, or find vulnerabilities in an app, API, website, or repo with Strix. +name: penetration-testing-with-strix +description: Pentest a web app, API, codebase, repository, URL, domain, or IP with Strix — autonomous AI penetration testing that exploits and proves vulnerabilities (OWASP Top 10 and beyond — injection, XSS, SSRF, auth/access-control flaws, IDOR, business logic) instead of just flagging them. Runs self-hosted with the open-source CLI or via the managed app.strix.ai cloud, and returns validated findings with proof-of-concept exploits (Markdown, JSON, CSV, SARIF). Use when the user asks to pentest, hack, security-scan, security-audit, or find vulnerabilities in an app, API, website, or repo. license: Apache-2.0 metadata: author: usestrix @@ -12,7 +12,7 @@ metadata: Strix runs autonomous AI pentesting agents that dynamically exploit a target and only report findings validated with a working proof-of-concept. There are **two ways to run it, built on the same engine and producing the same findings** — pick per situation, and mix them freely: - **Open-source CLI** (self-hosted) — runs on your machine in a Docker sandbox with your own LLM key. Free, fully local, BYO-LLM, air-gap capable. Docs: [docs.strix.ai](https://docs.strix.ai). -- **Cloud API** (managed) — runs on Strix's infrastructure via `https://app.strix.ai/api/v1`. No Docker, no LLM key, no local compute; adds team dashboards, scheduling, PR reviews, downloadable PDF/DOCX reports (Enterprise plan), and internal-network connectors. Docs: [docs.app.strix.ai](https://docs.app.strix.ai). Full workflow in the **strix-cloud-api** skill. +- **Cloud API** (managed) — runs on Strix's infrastructure via `https://app.strix.ai/api/v1`. No Docker, no LLM key, no local compute; adds team dashboards, scheduling, PR reviews, downloadable PDF/DOCX reports (Enterprise plan), and internal-network connectors. Docs: [docs.app.strix.ai](https://docs.app.strix.ai). Full workflow in the **managed-pentesting-with-strix** skill. ## Which one? (decide, don't default) @@ -112,7 +112,7 @@ Artifacts land in `strix_runs//`: # Option B — Cloud API (managed, no local infra) -Full details, asset registration, polling, reports, PR reviews, schedules, and webhooks are in the **strix-cloud-api** skill. Minimal launch-and-poll: +Full details, asset registration, polling, reports, PR reviews, schedules, and webhooks are in the **managed-pentesting-with-strix** skill. Minimal launch-and-poll: ```bash export STRIX_API_TOKEN="" # org-scoped bearer, from Settings → API Access at app.strix.ai @@ -136,7 +136,7 @@ Ask the user to create the token (and register the target as a domain/repository ## Reporting & next steps -Summarize findings by severity (critical/high/medium/low/info) and include the PoC evidence. To remediate and verify fixes (via either path), use the **strix-fix-findings** skill. To wire scanning into CI/CD, use the **strix-ci-setup** skill. +Summarize findings by severity (critical/high/medium/low/info) and include the PoC evidence. To remediate and verify fixes (via either path), use the **fix-security-vulnerabilities-with-strix** skill. To wire scanning into CI/CD, use the **ci-security-scanning-with-strix** skill. ## Safety