fix(runtime,interface): mount sources at advertised paths + surface scan failures in TUI

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/<workspace_subdir>``
  (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) <noreply@anthropic.com>
This commit is contained in:
0xallam
2026-04-26 07:27:36 -07:00
parent 0518599f29
commit 53188a7583
5 changed files with 84 additions and 58 deletions
+1 -23
View File
@@ -1,12 +1,10 @@
import atexit import atexit
import contextlib import contextlib
import logging import logging
import os
import signal import signal
import sys import sys
import threading import threading
import time import time
from pathlib import Path
from typing import Any from typing import Any
from rich.console import Console from rich.console import Console
@@ -37,26 +35,6 @@ def _resolve_sandbox_image() -> str:
return image 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 async def run_cli(args: Any) -> None: # noqa: PLR0915
console = Console() console = Console()
@@ -200,7 +178,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
scan_config=scan_config, scan_config=scan_config,
scan_id=args.run_name, scan_id=args.run_name,
image=_resolve_sandbox_image(), image=_resolve_sandbox_image(),
sources_path=_resolve_sources_path(args), local_sources=getattr(args, "local_sources", None) or [],
tracer=tracer, tracer=tracer,
interactive=bool(getattr(args, "interactive", False)), interactive=bool(getattr(args, "interactive", False)),
) )
+28 -20
View File
@@ -3,14 +3,12 @@ import asyncio
import atexit import atexit
import contextlib import contextlib
import logging import logging
import os
import signal import signal
import sys import sys
import threading import threading
from collections.abc import Callable from collections.abc import Callable
from importlib.metadata import PackageNotFoundError from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as pkg_version from importlib.metadata import version as pkg_version
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar 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_loop: asyncio.AbstractEventLoop | None = None
self._scan_stop_event = threading.Event() self._scan_stop_event = threading.Event()
self._scan_completed = 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._spinner_frame_index: int = 0 # Current animation frame index
self._sweep_num_squares: int = 6 # Number of squares in sweep animation 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() 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]: def _build_scan_config(self, args: argparse.Namespace) -> dict[str, Any]:
return { return {
"scan_id": args.run_name, "scan_id": args.run_name,
@@ -1102,7 +1092,7 @@ class StrixTUIApp(App): # type: ignore[misc]
return self._merge_renderables(renderables) 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] self, agent_id: str, agent_data: dict[str, Any]
) -> tuple[Text | None, Text, bool]: ) -> tuple[Text | None, Text, bool]:
status = agent_data.get("status", "running") status = agent_data.get("status", "running")
@@ -1141,6 +1131,16 @@ class StrixTUIApp(App): # type: ignore[misc]
keymap.append("Send message to retry", style="dim") keymap.append("Send message to retry", style="dim")
return (text, keymap, False) 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": if status == "waiting":
keymap = Text() keymap = Text()
keymap.append("Send message to resume", style="dim") keymap.append("Send message to resume", style="dim")
@@ -1409,13 +1409,12 @@ class StrixTUIApp(App): # type: ignore[misc]
try: try:
if not self._scan_stop_event.is_set(): if not self._scan_stop_event.is_set():
image = load_settings().runtime.image or "strix-sandbox:latest" image = load_settings().runtime.image or "strix-sandbox:latest"
sources_path = self._resolve_sources_path()
loop.run_until_complete( loop.run_until_complete(
run_strix_scan( run_strix_scan(
scan_config=self.scan_config, scan_config=self.scan_config,
scan_id=self.scan_config["run_name"], scan_id=self.scan_config["run_name"],
image=str(image), image=str(image),
sources_path=sources_path, local_sources=getattr(self.args, "local_sources", None) or [],
tracer=self.tracer, tracer=self.tracer,
bus=self.bus, bus=self.bus,
interactive=True, interactive=True,
@@ -1424,12 +1423,15 @@ class StrixTUIApp(App): # type: ignore[misc]
except (KeyboardInterrupt, asyncio.CancelledError): except (KeyboardInterrupt, asyncio.CancelledError):
logger.info("Scan interrupted by user") logger.info("Scan interrupted by user")
except (ConnectionError, TimeoutError): except (ConnectionError, TimeoutError) as e:
logging.exception("Network error during scan") logging.exception("Network error during scan")
except RuntimeError: self._scan_error = e
except RuntimeError as e:
logging.exception("Runtime error during scan") logging.exception("Runtime error during scan")
except Exception: self._scan_error = e
except Exception as e:
logging.exception("Unexpected error during scan") logging.exception("Unexpected error during scan")
self._scan_error = e
finally: finally:
# Best-effort sandbox teardown if early setup failed # Best-effort sandbox teardown if early setup failed
# before run_strix_scan's own ``finally`` ran. # 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.""" """Run strix in interactive TUI mode with textual."""
app = StrixTUIApp(args) app = StrixTUIApp(args)
await app.run_async() 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
+25 -5
View File
@@ -27,6 +27,7 @@ import contextlib
import json import json
import logging import logging
import uuid import uuid
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal 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.hooks import StrixOrchestrationHooks
from strix.orchestration.run_loop import run_with_continuation from strix.orchestration.run_loop import run_with_continuation
from strix.runtime import session_manager 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``. #: Default ``max_turns`` budget passed to ``Runner.run``.
@@ -173,7 +174,7 @@ async def run_strix_scan(
scan_config: dict[str, Any], scan_config: dict[str, Any],
scan_id: str | None = None, scan_id: str | None = None,
image: str, image: str,
sources_path: Path, local_sources: list[dict[str, str]] | None = None,
tracer: Any | None = None, tracer: Any | None = None,
bus: AgentMessageBus | None = None, bus: AgentMessageBus | None = None,
interactive: bool = False, interactive: bool = False,
@@ -192,7 +193,11 @@ async def run_strix_scan(
should pass a stable id. should pass a stable id.
image: Docker image tag for the sandbox (e.g. image: Docker image tag for the sandbox (e.g.
``"strix-sandbox:0.2.0"``). ``"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/<workspace_subdir>``. Pass ``None`` (or ``[]``)
for non-whitebox runs.
tracer: Optional Strix tracer. Stored in context for the tracer: Optional Strix tracer. Stored in context for the
telemetry hook chain. Pass ``None`` for unit tests. telemetry hook chain. Pass ``None`` for unit tests.
interactive: Renders the interactive-mode prompt block on the 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( bundle = await session_manager.create_or_reuse(
scan_id, scan_id,
image=image, image=image,
sources_path=sources_path, local_sources=local_sources or [],
) )
logger.info("Sandbox ready for scan %s", scan_id) logger.info("Sandbox ready for scan %s", scan_id)
@@ -450,12 +455,27 @@ async def run_strix_scan(
interactive=interactive, interactive=interactive,
session=root_session, session=root_session,
) )
except BaseException: except BaseException as exc:
logger.exception("Strix scan %s failed", scan_id) logger.exception("Strix scan %s failed", scan_id)
# Cancel any descendant tasks the root spawned before unwinding. # Cancel any descendant tasks the root spawned before unwinding.
# cancel_descendants is idempotent and handles the empty-tree case. # cancel_descendants is idempotent and handles the empty-tree case.
if root_id is not None: if root_id is not None:
await bus.cancel_descendants(root_id) 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 raise
finally: finally:
for s in sessions_to_close: for s in sessions_to_close:
+8
View File
@@ -48,6 +48,13 @@ async def _docker_backend(
NET_RAW caps + ``host.docker.internal`` host-gateway. Imports NET_RAW caps + ``host.docker.internal`` host-gateway. Imports
``docker`` lazily so deployments that target a non-Docker ``docker`` lazily so deployments that target a non-Docker
backend don't need the docker-py library installed. 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 import docker
from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions
@@ -57,6 +64,7 @@ async def _docker_backend(
client = StrixDockerSandboxClient(docker.from_env()) client = StrixDockerSandboxClient(docker.from_env())
options = DockerSandboxClientOptions(image=image, exposed_ports=exposed_ports) options = DockerSandboxClientOptions(image=image, exposed_ports=exposed_ports)
session = await client.create(options=options, manifest=manifest) session = await client.create(options=options, manifest=manifest)
await session.start()
return client, session return client, session
+22 -10
View File
@@ -16,9 +16,10 @@ next scan from starting.
from __future__ import annotations from __future__ import annotations
import logging 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 agents.sandbox.manifest import Environment, Manifest
from strix.config import load_settings 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 from strix.runtime.caido_bootstrap import bootstrap_caido
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -48,15 +45,17 @@ async def create_or_reuse(
scan_id: str, scan_id: str,
*, *,
image: str, image: str,
sources_path: Path, local_sources: list[dict[str, str]],
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return the existing bundle for ``scan_id`` or create a new one. """Return the existing bundle for ``scan_id`` or create a new one.
Args: Args:
scan_id: Caller-provided scan identifier (used as cache key). scan_id: Caller-provided scan identifier (used as cache key).
image: Docker image tag (e.g. ``"strix-sandbox:0.2.0"``). image: Docker image tag (e.g. ``"strix-sandbox:0.2.0"``).
sources_path: Host directory mounted into the container's local_sources: Each entry's ``source_path`` (host) is mounted at
``/workspace/sources`` so the agent can read user code. ``/workspace/<workspace_subdir>`` 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 Returns the bundle dict containing ``client``, ``session``, and
``caido_client``. ``caido_client``.
@@ -66,6 +65,19 @@ async def create_or_reuse(
logger.info("Reusing existing sandbox session for scan %s", scan_id) logger.info("Reusing existing sandbox session for scan %s", scan_id)
return cached return cached
# Build Manifest entries keyed by ``workspace_subdir`` — the SDK
# mounts each at ``/workspace/<key>``, 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 # Caido runs as an in-container sidecar; HTTP(S) traffic from any
# process started via ``session.exec`` (the SDK's Shell tool, etc.) # process started via ``session.exec`` (the SDK's Shell tool, etc.)
# picks up these env vars automatically. ``NO_PROXY`` keeps the # picks up these env vars automatically. ``NO_PROXY`` keeps the
@@ -73,7 +85,7 @@ async def create_or_reuse(
# through Caido. # through Caido.
container_caido_url = f"http://127.0.0.1:{_CONTAINER_CAIDO_PORT}" container_caido_url = f"http://127.0.0.1:{_CONTAINER_CAIDO_PORT}"
manifest = Manifest( manifest = Manifest(
entries={"sources": LocalDir(src=sources_path)}, entries=entries,
environment=Environment( environment=Environment(
value={ value={
"PYTHONUNBUFFERED": "1", "PYTHONUNBUFFERED": "1",