From 53188a75837a96273a60c4e53b341d9558764201 Mon Sep 17 00:00:00 2001 From: 0xallam Date: Sun, 26 Apr 2026 07:27:36 -0700 Subject: [PATCH] fix(runtime,interface): mount sources at advertised paths + surface scan failures in TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes that surfaced from a single broken run. (1) Source mounting was double-broken: - ``session_manager.create_or_reuse`` mounted the *parent* of the first local source under a hardcoded ``"sources"`` key, so the host's unrelated content leaked in at ``/workspace/sources/...`` while the agent's task prompt advertised ``/workspace/`` (from ``_build_root_task``). Result: the agent looked at ``/workspace/empty/`` (per the prompt), found nothing, and bailed. - ``backends._docker_backend`` never called ``await session.start()`` after ``client.create()`` — the SDK's manifest application (``LocalDir`` materialization, mount setup) only runs inside ``start()`` (or ``async with session:``). So even with the right ``entries`` the workspace would have been empty anyway. Fix: thread ``args.local_sources`` (already populated by ``collect_local_sources``) all the way through to the session manager, build ``Manifest.entries`` keyed by each source's ``workspace_subdir``, and call ``session.start()`` in the docker backend so the SDK actually materializes the entries. Drop the now-unused ``_resolve_sources_path`` helpers from ``cli.py`` and ``tui.py``. (2) Scan-failure visibility was nonexistent in TUI mode: - The SDK's ``on_agent_end`` hook only fires after the agent reaches its first turn. A failure earlier (model routing, sandbox bring-up, …) left the root agent stuck at ``status=running`` in the bus and tracer, so the TUI animated "Initializing" forever. - ``scan_target`` in ``tui.py`` caught the exception and called ``logging.exception`` but never propagated it. ``run_tui`` returned cleanly when the user finally ctrl-q'd, so ``main.py`` happily printed the success-completion banner over a dead scan. Fix: in ``run_strix_scan``'s ``except BaseException`` block, finalize the root agent as ``"failed"`` in both the bus and the tracer (with the error message attached). Capture the exception on ``StrixTUIApp._scan_error`` from the scan thread; ``run_tui`` re-raises it after ``app.run_async()`` returns so ``main.py``'s existing handler prints the traceback. Add a ``"failed"`` branch to ``_get_status_display_content`` that shows the error message in red, mirroring the existing ``llm_failed`` branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- strix/interface/cli.py | 24 +--------------- strix/interface/tui.py | 48 +++++++++++++++++++------------- strix/orchestration/scan.py | 30 ++++++++++++++++---- strix/runtime/backends.py | 8 ++++++ strix/runtime/session_manager.py | 32 ++++++++++++++------- 5 files changed, 84 insertions(+), 58 deletions(-) diff --git a/strix/interface/cli.py b/strix/interface/cli.py index 5369e5f..4846241 100644 --- a/strix/interface/cli.py +++ b/strix/interface/cli.py @@ -1,12 +1,10 @@ import atexit import contextlib import logging -import os import signal import sys import threading import time -from pathlib import Path from typing import Any from rich.console import Console @@ -37,26 +35,6 @@ def _resolve_sandbox_image() -> str: return image -def _resolve_sources_path(args: Any) -> Path: - """Pick the host directory to mount into ``/workspace/sources``. - - - With ``--local-sources``, mount the parent of the first source so - the agent can walk down into the actual tree. - - Otherwise, a per-run scratch dir under ``$XDG_CACHE_HOME/strix``. - """ - local_sources: list[dict[str, str]] | None = getattr(args, "local_sources", None) - if local_sources: - first = local_sources[0] - host_path = first.get("host_path") or first.get("source_path") or first.get("path") - if host_path: - return Path(host_path).expanduser().resolve().parent - - cache_root = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache") - sources = Path(cache_root) / "strix" / "sources" / str(args.run_name) - sources.mkdir(parents=True, exist_ok=True) - return sources - - async def run_cli(args: Any) -> None: # noqa: PLR0915 console = Console() @@ -200,7 +178,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915 scan_config=scan_config, scan_id=args.run_name, image=_resolve_sandbox_image(), - sources_path=_resolve_sources_path(args), + local_sources=getattr(args, "local_sources", None) or [], tracer=tracer, interactive=bool(getattr(args, "interactive", False)), ) diff --git a/strix/interface/tui.py b/strix/interface/tui.py index 2c4a3c2..bf1e1b1 100644 --- a/strix/interface/tui.py +++ b/strix/interface/tui.py @@ -3,14 +3,12 @@ import asyncio import atexit import contextlib import logging -import os import signal import sys import threading from collections.abc import Callable from importlib.metadata import PackageNotFoundError from importlib.metadata import version as pkg_version -from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar @@ -726,6 +724,10 @@ class StrixTUIApp(App): # type: ignore[misc] self._scan_loop: asyncio.AbstractEventLoop | None = None self._scan_stop_event = threading.Event() self._scan_completed = threading.Event() + # Captured by ``scan_target`` when the scan thread crashes; read + # by ``run_tui`` after ``run_async()`` returns so the user sees + # the traceback on stderr instead of just a silent UI hang. + self._scan_error: BaseException | None = None self._spinner_frame_index: int = 0 # Current animation frame index self._sweep_num_squares: int = 6 # Number of squares in sweep animation @@ -743,18 +745,6 @@ class StrixTUIApp(App): # type: ignore[misc] self._setup_cleanup_handlers() - def _resolve_sources_path(self) -> Path: - local_sources = getattr(self.args, "local_sources", None) or [] - if local_sources: - first = local_sources[0] - host_path = first.get("host_path") or first.get("source_path") or first.get("path") - if host_path: - return Path(host_path).expanduser().resolve().parent - cache_root = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache") - sources = Path(cache_root) / "strix" / "sources" / str(self.args.run_name) - sources.mkdir(parents=True, exist_ok=True) - return sources - def _build_scan_config(self, args: argparse.Namespace) -> dict[str, Any]: return { "scan_id": args.run_name, @@ -1102,7 +1092,7 @@ class StrixTUIApp(App): # type: ignore[misc] return self._merge_renderables(renderables) - def _get_status_display_content( + def _get_status_display_content( # noqa: PLR0911 self, agent_id: str, agent_data: dict[str, Any] ) -> tuple[Text | None, Text, bool]: status = agent_data.get("status", "running") @@ -1141,6 +1131,16 @@ class StrixTUIApp(App): # type: ignore[misc] keymap.append("Send message to retry", style="dim") return (text, keymap, False) + if status == "failed": + error_msg = agent_data.get("error_message", "") + text = Text() + if error_msg: + text.append(error_msg, style="red") + else: + text.append("Scan failed", style="red") + self._stop_dot_animation() + return (text, Text(), False) + if status == "waiting": keymap = Text() keymap.append("Send message to resume", style="dim") @@ -1409,13 +1409,12 @@ class StrixTUIApp(App): # type: ignore[misc] try: if not self._scan_stop_event.is_set(): image = load_settings().runtime.image or "strix-sandbox:latest" - sources_path = self._resolve_sources_path() loop.run_until_complete( run_strix_scan( scan_config=self.scan_config, scan_id=self.scan_config["run_name"], image=str(image), - sources_path=sources_path, + local_sources=getattr(self.args, "local_sources", None) or [], tracer=self.tracer, bus=self.bus, interactive=True, @@ -1424,12 +1423,15 @@ class StrixTUIApp(App): # type: ignore[misc] except (KeyboardInterrupt, asyncio.CancelledError): logger.info("Scan interrupted by user") - except (ConnectionError, TimeoutError): + except (ConnectionError, TimeoutError) as e: logging.exception("Network error during scan") - except RuntimeError: + self._scan_error = e + except RuntimeError as e: logging.exception("Runtime error during scan") - except Exception: + self._scan_error = e + except Exception as e: logging.exception("Unexpected error during scan") + self._scan_error = e finally: # Best-effort sandbox teardown if early setup failed # before run_strix_scan's own ``finally`` ran. @@ -1995,3 +1997,9 @@ async def run_tui(args: argparse.Namespace) -> None: """Run strix in interactive TUI mode with textual.""" app = StrixTUIApp(args) await app.run_async() + # Propagate scan-thread failures: ``app.run_async`` returns normally + # when the user quits (ctrl-q) regardless of whether the scan + # crashed. Without this re-raise, ``main.py`` would treat a failed + # scan as success and print the completion banner. + if app._scan_error is not None: + raise app._scan_error diff --git a/strix/orchestration/scan.py b/strix/orchestration/scan.py index aceaf1a..8006ea7 100644 --- a/strix/orchestration/scan.py +++ b/strix/orchestration/scan.py @@ -27,6 +27,7 @@ import contextlib import json import logging import uuid +from datetime import UTC, datetime from pathlib import Path from typing import TYPE_CHECKING, Any, Literal @@ -45,7 +46,7 @@ from strix.orchestration.filter import inject_messages_filter from strix.orchestration.hooks import StrixOrchestrationHooks from strix.orchestration.run_loop import run_with_continuation from strix.runtime import session_manager -from strix.telemetry.logging import set_scan_id, setup_scan_logging +from strix.telemetry.logging import set_agent_id, set_scan_id, setup_scan_logging #: Default ``max_turns`` budget passed to ``Runner.run``. @@ -173,7 +174,7 @@ async def run_strix_scan( scan_config: dict[str, Any], scan_id: str | None = None, image: str, - sources_path: Path, + local_sources: list[dict[str, str]] | None = None, tracer: Any | None = None, bus: AgentMessageBus | None = None, interactive: bool = False, @@ -192,7 +193,11 @@ async def run_strix_scan( should pass a stable id. image: Docker image tag for the sandbox (e.g. ``"strix-sandbox:0.2.0"``). - sources_path: Host directory mounted into ``/workspace/sources``. + local_sources: Per-source mount specs from + :func:`strix.interface.utils.collect_local_sources` — + each entry's ``source_path`` (host) is bind-mounted at + ``/workspace/``. Pass ``None`` (or ``[]``) + for non-whitebox runs. tracer: Optional Strix tracer. Stored in context for the telemetry hook chain. Pass ``None`` for unit tests. interactive: Renders the interactive-mode prompt block on the @@ -300,7 +305,7 @@ async def run_strix_scan( bundle = await session_manager.create_or_reuse( scan_id, image=image, - sources_path=sources_path, + local_sources=local_sources or [], ) logger.info("Sandbox ready for scan %s", scan_id) @@ -450,12 +455,27 @@ async def run_strix_scan( interactive=interactive, session=root_session, ) - except BaseException: + except BaseException as exc: logger.exception("Strix scan %s failed", scan_id) # Cancel any descendant tasks the root spawned before unwinding. # cancel_descendants is idempotent and handles the empty-tree case. if root_id is not None: await bus.cancel_descendants(root_id) + # The SDK's on_agent_end hook only fires after a successful + # ``Runner.run_streamed`` reaches the agent's first turn. A + # failure earlier (e.g., model-provider routing, sandbox + # bring-up) leaves the root stuck at status="running" — the + # TUI keeps animating "Initializing" forever. Finalize it + # here so the bus + tracer reflect reality, and stash the + # error message for the status-line display. + error_message = f"{type(exc).__name__}: {exc}" + if tracer is not None and root_id in getattr(tracer, "agents", {}): + tracer.agents[root_id]["status"] = "failed" + tracer.agents[root_id]["error_message"] = error_message + tracer.agents[root_id]["updated_at"] = datetime.now(UTC).isoformat() + with contextlib.suppress(Exception): + await bus.finalize(root_id, "failed") + set_agent_id(None) raise finally: for s in sessions_to_close: diff --git a/strix/runtime/backends.py b/strix/runtime/backends.py index 9fcafef..e4fa482 100644 --- a/strix/runtime/backends.py +++ b/strix/runtime/backends.py @@ -48,6 +48,13 @@ async def _docker_backend( NET_RAW caps + ``host.docker.internal`` host-gateway. Imports ``docker`` lazily so deployments that target a non-Docker backend don't need the docker-py library installed. + + ``session.start()`` is what materializes the manifest entries + (LocalDir copies, mount setup, etc.) into the running container — + the SDK's ``client.create()`` only builds the inner session object + without applying the manifest. ``async with session:`` would call it + too, but Strix manages session lifetime explicitly via + ``client.delete()`` so we trigger ``start()`` ourselves. """ import docker from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions @@ -57,6 +64,7 @@ async def _docker_backend( client = StrixDockerSandboxClient(docker.from_env()) options = DockerSandboxClientOptions(image=image, exposed_ports=exposed_ports) session = await client.create(options=options, manifest=manifest) + await session.start() return client, session diff --git a/strix/runtime/session_manager.py b/strix/runtime/session_manager.py index 3859884..1394495 100644 --- a/strix/runtime/session_manager.py +++ b/strix/runtime/session_manager.py @@ -16,9 +16,10 @@ next scan from starting. from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any +from pathlib import Path +from typing import Any -from agents.sandbox.entries import LocalDir +from agents.sandbox.entries import BaseEntry, LocalDir from agents.sandbox.manifest import Environment, Manifest from strix.config import load_settings @@ -26,10 +27,6 @@ from strix.runtime.backends import get_backend from strix.runtime.caido_bootstrap import bootstrap_caido -if TYPE_CHECKING: - from pathlib import Path - - logger = logging.getLogger(__name__) @@ -48,15 +45,17 @@ async def create_or_reuse( scan_id: str, *, image: str, - sources_path: Path, + local_sources: list[dict[str, str]], ) -> dict[str, Any]: """Return the existing bundle for ``scan_id`` or create a new one. Args: scan_id: Caller-provided scan identifier (used as cache key). image: Docker image tag (e.g. ``"strix-sandbox:0.2.0"``). - sources_path: Host directory mounted into the container's - ``/workspace/sources`` so the agent can read user code. + local_sources: Each entry's ``source_path`` (host) is mounted at + ``/workspace/`` inside the container — the + same path the root-task prompt advertises. Empty list means + no host code is mounted (web/IP-only scans). Returns the bundle dict containing ``client``, ``session``, and ``caido_client``. @@ -66,6 +65,19 @@ async def create_or_reuse( logger.info("Reusing existing sandbox session for scan %s", scan_id) return cached + # Build Manifest entries keyed by ``workspace_subdir`` — the SDK + # mounts each at ``/workspace/``, which is exactly the path + # ``_build_root_task`` puts in the agent's task prompt. Mounting + # only the listed source dirs (not their parent) avoids leaking + # unrelated host content into the sandbox. + entries: dict[str | Path, BaseEntry] = {} + for src in local_sources: + ws_subdir = src.get("workspace_subdir") or "" + host_path = src.get("source_path") or "" + if not ws_subdir or not host_path: + continue + entries[ws_subdir] = LocalDir(src=Path(host_path).expanduser().resolve()) + # Caido runs as an in-container sidecar; HTTP(S) traffic from any # process started via ``session.exec`` (the SDK's Shell tool, etc.) # picks up these env vars automatically. ``NO_PROXY`` keeps the @@ -73,7 +85,7 @@ async def create_or_reuse( # through Caido. container_caido_url = f"http://127.0.0.1:{_CONTAINER_CAIDO_PORT}" manifest = Manifest( - entries={"sources": LocalDir(src=sources_path)}, + entries=entries, environment=Environment( value={ "PYTHONUNBUFFERED": "1",