mirror of
https://github.com/usestrix/strix.git
synced 2026-08-23 11:22:37 +02:00
Support large target repos with with bind-mount option. (#577)
* fix: resolve pre-commit check failures - Change RuntimeError to TypeError for type validation in report/writer.py - Update pyupgrade to v3.21.2 for Python 3.14 compatibility * chore: add pytest test infrastructure Mirror the layout introduced on feature/438-token_budget: pytest + pytest-asyncio dev deps, asyncio_mode auto, a tests.* mypy override, and pytest in the mypy pre-commit hook deps so the tests/ package type-checks. * feat: add --mount and large-target pre-flight for local repos (#492) Large local targets were copied into the sandbox file-by-file via the SDK LocalDir entry, which stalls on big repos and could leave /workspace empty. - --mount <path> bind-mounts a host directory read-only at /workspace/<subdir> instead of copying it, bypassing the per-file stream. - A size pre-flight (STRIX_MAX_LOCAL_COPY_MB, default 1024) fails fast with a clear message suggesting --mount when a non-mounted local target is too big. * fix: reject empty --mount paths An empty or whitespace-only --mount value resolves to the current working directory and would silently bind-mount it into the sandbox. Reject it. * fix: dedupe local targets so a dir is never both copied and mounted If the same directory is passed via --target and --mount (or as duplicate values), it previously produced two targets — copied AND bind-mounted, and the copied one could trip the size pre-flight. Dedupe by resolved path, preferring the bind mount. * fix: treat non-positive STRIX_MAX_LOCAL_COPY_MB as disabled Previously a value of 0 (or negative) made every local target count as oversized, aborting all local scans. Now <= 0 disables the pre-flight. * fix: log unreadable subtrees during size pre-flight os.walk silently swallowed directory-listing errors, so a permission-denied subtree could make a large repo under-count and slip past the pre-flight. Surface such omissions via an onerror warning. * docs: document --mount and STRIX_MAX_LOCAL_COPY_MB Add CLI reference + example for --mount, document the size pre-flight env var, note the read-only-is-not-a-hard-boundary caveat and that remote repos are not size-checked, and clarify the backends docstring on when bind mounts apply. * Update strix/interface/main.py * Update strix/runtime/docker_client.py ---------
This commit is contained in:
@@ -22,6 +22,7 @@ async def _docker_backend(
|
||||
image: str,
|
||||
manifest: Manifest,
|
||||
exposed_ports: tuple[int, ...],
|
||||
bind_mounts: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[Any, Any]:
|
||||
"""Bring up a session backed by the local Docker daemon.
|
||||
|
||||
@@ -31,11 +32,15 @@ async def _docker_backend(
|
||||
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
|
||||
(LocalDir copies and manifest-declared volume/FUSE mounts) 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.
|
||||
|
||||
``bind_mounts`` are host directories (e.g. large repos passed via
|
||||
``--mount``) bind-mounted read-only; unlike manifest entries they are
|
||||
applied by Docker at container-create time, not by ``start()``.
|
||||
"""
|
||||
import docker
|
||||
from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions
|
||||
@@ -43,6 +48,7 @@ async def _docker_backend(
|
||||
from strix.runtime.docker_client import StrixDockerSandboxClient
|
||||
|
||||
client = StrixDockerSandboxClient(docker.from_env())
|
||||
client.strix_bind_mounts = bind_mounts or []
|
||||
options = DockerSandboxClientOptions(image=image, exposed_ports=exposed_ports)
|
||||
session = await client.create(options=options, manifest=manifest)
|
||||
await session.start()
|
||||
|
||||
@@ -38,6 +38,7 @@ from agents.sandbox.sandboxes.docker import (
|
||||
from agents.sandbox.session.sandbox_session import SandboxSession
|
||||
from docker import errors as docker_errors # type: ignore[import-untyped, unused-ignore]
|
||||
from docker.models.containers import Container # type: ignore[import-untyped, unused-ignore]
|
||||
from docker.types import Mount as DockerSDKMount # type: ignore[import-untyped, unused-ignore]
|
||||
from docker.utils import parse_repository_tag # type: ignore[import-untyped, unused-ignore]
|
||||
|
||||
|
||||
@@ -45,6 +46,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StrixDockerSandboxClient(DockerSandboxClient):
|
||||
# Host directories to bind-mount into the container, set by the docker
|
||||
# backend before ``create()``. Each item is ``{source, target, read_only}``.
|
||||
strix_bind_mounts: list[dict[str, Any]] = [] # overridden per-instance in backends.py
|
||||
|
||||
async def _create_container(
|
||||
self,
|
||||
image: str,
|
||||
@@ -111,6 +116,21 @@ class StrixDockerSandboxClient(DockerSandboxClient):
|
||||
extra_hosts = create_kwargs.setdefault("extra_hosts", {})
|
||||
extra_hosts["host.docker.internal"] = "host-gateway"
|
||||
|
||||
# Strix injection: host bind mounts (e.g. large repos passed via --mount)
|
||||
# that bypass the SDK's file-by-file LocalDir copy.
|
||||
bind_mounts = getattr(self, "strix_bind_mounts", ())
|
||||
if bind_mounts:
|
||||
mounts = create_kwargs.setdefault("mounts", [])
|
||||
for spec in bind_mounts:
|
||||
mounts.append(
|
||||
DockerSDKMount(
|
||||
target=spec["target"],
|
||||
source=spec["source"],
|
||||
type="bind",
|
||||
read_only=spec.get("read_only", True),
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Creating sandbox container: image=%s caps=%s exposed_ports=%s",
|
||||
image,
|
||||
|
||||
@@ -23,30 +23,59 @@ _CONTAINER_CAIDO_PORT = 48080
|
||||
|
||||
_SESSION_CACHE: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# Manifest root inside the container; entry keys hang off this path.
|
||||
_WORKSPACE_ROOT = "/workspace"
|
||||
|
||||
|
||||
def build_session_entries(
|
||||
local_sources: list[dict[str, Any]],
|
||||
) -> tuple[dict[str | Path, BaseEntry], list[dict[str, Any]]]:
|
||||
"""Split local sources into copied manifest entries and host bind mounts.
|
||||
|
||||
Sources flagged ``mount`` are bind-mounted read-only at
|
||||
``/workspace/<workspace_subdir>`` (not added to the manifest, so the SDK
|
||||
does not stream them in file-by-file). Every other source becomes a
|
||||
``LocalDir`` entry copied into the container as before.
|
||||
"""
|
||||
entries: dict[str | Path, BaseEntry] = {}
|
||||
bind_mounts: list[dict[str, Any]] = []
|
||||
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
|
||||
resolved = Path(host_path).expanduser().resolve()
|
||||
if src.get("mount"):
|
||||
bind_mounts.append(
|
||||
{
|
||||
"source": str(resolved),
|
||||
"target": f"{_WORKSPACE_ROOT}/{ws_subdir}",
|
||||
"read_only": True,
|
||||
}
|
||||
)
|
||||
else:
|
||||
entries[ws_subdir] = LocalDir(src=resolved)
|
||||
return entries, bind_mounts
|
||||
|
||||
|
||||
async def create_or_reuse(
|
||||
scan_id: str,
|
||||
*,
|
||||
image: str,
|
||||
local_sources: list[dict[str, str]],
|
||||
local_sources: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
"""Return the existing session bundle for ``scan_id`` or create a new one.
|
||||
|
||||
Each ``local_sources`` entry mounts its host ``source_path`` at
|
||||
``/workspace/<workspace_subdir>`` inside the container.
|
||||
Each ``local_sources`` entry exposes its host ``source_path`` at
|
||||
``/workspace/<workspace_subdir>`` inside the container — copied in, or
|
||||
bind-mounted read-only when the entry is flagged ``mount``.
|
||||
"""
|
||||
cached = _SESSION_CACHE.get(scan_id)
|
||||
if cached is not None:
|
||||
logger.info("Reusing existing sandbox session for scan %s", scan_id)
|
||||
return cached
|
||||
|
||||
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())
|
||||
entries, bind_mounts = build_session_entries(local_sources)
|
||||
|
||||
# Caido runs as an in-container sidecar; HTTP(S) traffic from any
|
||||
# process started via ``session.exec`` (the SDK's Shell tool, etc.)
|
||||
@@ -81,6 +110,7 @@ async def create_or_reuse(
|
||||
image=image,
|
||||
manifest=manifest,
|
||||
exposed_ports=(_CONTAINER_CAIDO_PORT,),
|
||||
bind_mounts=bind_mounts,
|
||||
)
|
||||
|
||||
caido_endpoint = await session.resolve_exposed_port(_CONTAINER_CAIDO_PORT)
|
||||
|
||||
Reference in New Issue
Block a user