Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ ignore = [
"strix/core/runner.py" = ["TC003", "PLR0912", "PLR0915", "PLC0415"]
# ReportState carries scan artifact/report fields and
# a runtime ``Callable`` annotation on ``vulnerability_found_callback``.
"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401"]
"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401", "PLC0415"]
"strix/report/usage.py" = ["PLC0415"]
"strix/config/models.py" = ["PLC0415"]
# Interface utility branches per scope-mode / target-type combination;
Expand Down
4 changes: 4 additions & 0 deletions strix/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ class RuntimeSettings(BaseSettings):
max_local_copy_mb: int = Field(default=1024, alias="STRIX_MAX_LOCAL_COPY_MB")
# Max screenshot/image tool outputs kept live per agent context (0 = none).
max_context_images: int = Field(default=3, ge=0, alias="STRIX_MAX_CONTEXT_IMAGES")
# Emit a self-contained HTML findings report (report.html) on scan
# completion, alongside the markdown/CSV/JSON/SARIF outputs. Set to false
# (STRIX_HTML_REPORT=0) to disable.
html_report: bool = Field(default=True, alias="STRIX_HTML_REPORT")


class TelemetrySettings(BaseSettings):
Expand Down
14 changes: 9 additions & 5 deletions strix/interface/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -663,11 +663,15 @@ def parse_arguments() -> argparse.Namespace:
details = "; ".join(
f"{path} ({size / (1024 * 1024):.0f} MB)" for path, size in oversized
)
parser.error(
f"Local target too large to stream into the sandbox: {details}. "
f"The limit is {max_local_copy_mb} MB "
"(set STRIX_MAX_LOCAL_COPY_MB to change it). Re-run with "
"--mount <path> to bind-mount the directory instead of copying it."
# Issue #725: do NOT refuse large local targets. They are
# bind-mounted read-only into the sandbox by default (applied at
# container-create time, no file-by-file copy), so a large tree no
# longer stalls the scan. Warn for visibility and proceed.
logger.warning(
"Large local target(s) detected: %s. Bind-mounting read-only "
"into the sandbox (no file-by-file copy); the scan starts "
"immediately. Set STRIX_MAX_LOCAL_COPY_MB to tune this check.",
details,
)
Comment on lines +669 to 675

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Explicit Copies Bypass Size Guard

An oversized local_code target with mount: False still reaches this warning, but collect_local_sources preserves that flag and build_session_entries creates a file-by-file LocalDir copy. The command now proceeds into the same long import that the size guard previously blocked while incorrectly reporting that the target will be bind-mounted.

Prompt To Fix With AI
This is a comment left during a code review.
Path: strix/interface/main.py
Line: 650-656

Comment:
**Explicit Copies Bypass Size Guard**

An oversized `local_code` target with `mount: False` still reaches this warning, but `collect_local_sources` preserves that flag and `build_session_entries` creates a file-by-file `LocalDir` copy. The command now proceeds into the same long import that the size guard previously blocked while incorrectly reporting that the target will be bind-mounted.

How can I resolve this? If you propose a fix, please make it concise.


return args
Expand Down
29 changes: 25 additions & 4 deletions strix/interface/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import atexit
import contextlib
import logging
import queue
import signal
import sys
import threading
Expand Down Expand Up @@ -35,6 +36,7 @@
from strix.config.models import is_recommended_or_frontier_model
from strix.core.hooks import BudgetExceededError
from strix.core.runner import run_strix_scan
from strix.interface.tui.event_pump import DEFAULT_MAX_DRAIN, drain_queue
from strix.interface.tui.live_view import TuiLiveView
from strix.interface.tui.messages import send_user_message_to_agent
from strix.interface.tui.renderers import render_tool_widget
Expand Down Expand Up @@ -796,6 +798,10 @@ def __init__(self, args: argparse.Namespace):
self._displayed_events: list[str] = []

self._scan_thread: threading.Thread | None = None
# Scan thread enqueues SDK events here (non-blocking); a UI-loop timer
# drains them in bounded batches so a high event rate during streaming
# cannot block the scan thread or starve UI input handling.
self._sdk_event_queue: queue.Queue[tuple[str, Any]] = queue.Queue()
self._viewer_httpd: Any = None
self._viewer_url: str | None = None
self._scan_loop: asyncio.AbstractEventLoop | None = None
Expand Down Expand Up @@ -966,6 +972,9 @@ def _hide_splash_screen(self) -> None:
self._start_scan_thread()

self.set_interval(0.5, self._update_ui)
# Fast, bounded drain of the SDK event queue keeps the UI responsive to
# input even under a high scan event rate.
self.set_interval(0.05, self._drain_sdk_events)

def _update_ui(self) -> None:
if self.show_splash:
Expand Down Expand Up @@ -1508,10 +1517,18 @@ def scan_target() -> None:
self._scan_thread.start()

def _capture_sdk_event(self, agent_id: str, event: Any) -> None:
try:
self.call_from_thread(self._record_sdk_event, agent_id, event)
except RuntimeError:
self._record_sdk_event(agent_id, event)
# Non-blocking hand-off: enqueue and return immediately so the scan
# thread never blocks on the UI thread per event. The UI-loop timer
# (_drain_sdk_events) ingests batches in order. Replaces the previous
# per-event blocking call_from_thread that flooded the UI pump.
self._sdk_event_queue.put_nowait((agent_id, event))

def _drain_sdk_events(self) -> None:
drain_queue(
self._sdk_event_queue,
lambda item: self._record_sdk_event(item[0], item[1]),
max_items=DEFAULT_MAX_DRAIN,
)

def _record_sdk_event(self, agent_id: str, event: Any) -> None:
self.live_view.ingest_sdk_event(agent_id, event)
Expand Down Expand Up @@ -1688,6 +1705,10 @@ def handle_tree_node_selected(self, event: Tree.NodeSelected) -> None:

def _send_user_message(self, message: str) -> None:
if not self.selected_agent_id:
self.notify(
"Select an agent in the tree first, then send your message.",
severity="warning",
)
return

logger.info(
Expand Down
55 changes: 55 additions & 0 deletions strix/interface/tui/event_pump.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Thread-safe SDK-event pump for the TUI.

The scan runs on a separate thread and produces SDK events at a high rate during
LLM streaming. Delivering each event to the Textual UI with a blocking
``App.call_from_thread`` per event blocks the scan thread and floods the UI
message pump, starving input handling. Instead the scan thread enqueues events
(non-blocking) and the UI loop drains them in bounded batches via
:func:`drain_queue`, keeping the UI responsive.

``drain_queue`` is a pure function (no Textual/UI dependency) so it can be unit
tested without a live terminal app.
"""

from __future__ import annotations

import logging
import queue
from typing import TYPE_CHECKING


if TYPE_CHECKING:
from collections.abc import Callable


logger = logging.getLogger(__name__)

# Default number of events ingested per UI tick. Bounds UI work per frame so a
# burst of events cannot monopolize the event loop.
DEFAULT_MAX_DRAIN = 100


def drain_queue[T](
q: queue.Queue[T],
sink: Callable[[T], None],
*,
max_items: int = DEFAULT_MAX_DRAIN,
) -> int:
"""Drain up to ``max_items`` items from ``q`` (FIFO) into ``sink``.

Returns the number of items processed. Remaining items are left on the queue
for the next call (never dropped). A ``sink`` error on one item is logged and
does not stop the drain.
"""
processed = 0
for _ in range(max_items):
try:
item = q.get_nowait()
except queue.Empty:
break
try:
sink(item)
except Exception:
logger.exception("SDK event ingest failed for one item; continuing")
processed += 1
return processed
7 changes: 6 additions & 1 deletion strix/interface/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1225,11 +1225,16 @@ def collect_local_sources(targets_info: list[dict[str, Any]]) -> list[dict[str,
workspace_subdir = details.get("workspace_subdir")

if target_info["type"] == "local_code" and "target_path" in details:
# Local directory targets bind-mount read-only by default (issue
# #725): the SDK LocalDir entry copies a tree into the sandbox
# file-by-file, which stalls for hours on large repos. A bind mount
# is applied at container-create time and is effectively instant.
# An explicit ``mount: False`` still forces the file-by-file copy.
local_sources.append(
{
"source_path": details["target_path"],
"workspace_subdir": workspace_subdir,
"mount": bool(details.get("mount", False)),
"mount": bool(details.get("mount", True)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Remote Daemon Cannot See Target

When Docker uses a remote daemon, this default passes the CLI machine's local path directly as a host bind source. The daemon resolves that path on its own host, so an ordinary --target can fail during container creation or expose the wrong directory; the previous LocalDir path uploaded the client-side files.

Prompt To Fix With AI
This is a comment left during a code review.
Path: strix/interface/utils.py
Line: 1237

Comment:
**Remote Daemon Cannot See Target**

When Docker uses a remote daemon, this default passes the CLI machine's local path directly as a host bind source. The daemon resolves that path on its own host, so an ordinary `--target` can fail during container creation or expose the wrong directory; the previous `LocalDir` path uploaded the client-side files.

How can I resolve this? If you propose a fix, please make it concise.

}
)

Expand Down
Loading