mirror of
https://github.com/usestrix/strix.git
synced 2026-08-25 12:22:37 +02:00
feat(runtime): mount local targets instead of copying them in (#958)
Co-authored-by: Ahmed Allam <ahmed39652003@gmail.com>
This commit is contained in:
co-authored by
Ahmed Allam
parent
b6cf156e95
commit
dbc427d816
+25
-13
@@ -31,16 +31,11 @@ async def _docker_backend(
|
||||
``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 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()``.
|
||||
``session.start()`` is what materializes the manifest into the running
|
||||
container — the SDK's ``client.create()`` only builds the inner session
|
||||
object without applying it. ``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
|
||||
@@ -59,6 +54,8 @@ _BACKENDS: dict[str, SandboxBackend] = {
|
||||
"docker": _docker_backend,
|
||||
}
|
||||
|
||||
_BIND_MOUNT_BACKENDS: set[str] = {"docker"}
|
||||
|
||||
|
||||
def get_backend(name: str) -> SandboxBackend:
|
||||
"""Return the backend factory for ``name`` or raise.
|
||||
@@ -78,15 +75,30 @@ def get_backend(name: str) -> SandboxBackend:
|
||||
return backend
|
||||
|
||||
|
||||
def register_backend(name: str, backend: SandboxBackend) -> None:
|
||||
def register_backend(
|
||||
name: str,
|
||||
backend: SandboxBackend,
|
||||
*,
|
||||
supports_bind_mounts: bool = False,
|
||||
) -> None:
|
||||
"""Register a custom backend under ``name``.
|
||||
|
||||
Intended for downstream users who ship their own runtime — register
|
||||
before any ``session_manager.create_or_reuse`` call. Re-registering
|
||||
an existing name overwrites the prior entry.
|
||||
an existing name overwrites the prior entry. ``supports_bind_mounts``
|
||||
defaults to False: a remote runtime cannot see the caller's filesystem, so
|
||||
it is handed local sources as manifest entries to upload instead.
|
||||
"""
|
||||
_BACKENDS[name] = backend
|
||||
logger.info("Registered sandbox backend: %s", name)
|
||||
if supports_bind_mounts:
|
||||
_BIND_MOUNT_BACKENDS.add(name)
|
||||
else:
|
||||
_BIND_MOUNT_BACKENDS.discard(name)
|
||||
logger.info("Registered sandbox backend: %s (bind mounts: %s)", name, supports_bind_mounts)
|
||||
|
||||
|
||||
def backend_supports_bind_mounts(name: str) -> bool:
|
||||
return name in _BIND_MOUNT_BACKENDS
|
||||
|
||||
|
||||
def supported_backends() -> list[str]:
|
||||
|
||||
@@ -237,18 +237,18 @@ class StrixDockerSandboxClient(DockerSandboxClient):
|
||||
_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.
|
||||
bind_mounts = getattr(self, "strix_bind_mounts", ())
|
||||
# Strix injection: local source trees, sorted shallowest-first so a
|
||||
# nested spec lands on top of the tree it covers.
|
||||
bind_mounts = self.strix_bind_mounts or ()
|
||||
if bind_mounts:
|
||||
mounts = create_kwargs.setdefault("mounts", [])
|
||||
for spec in bind_mounts:
|
||||
for spec in sorted(bind_mounts, key=lambda s: str(s["target"]).count("/")):
|
||||
mounts.append(
|
||||
DockerSDKMount(
|
||||
target=spec["target"],
|
||||
source=spec["source"],
|
||||
type="bind",
|
||||
read_only=spec.get("read_only", True),
|
||||
read_only=spec.get("read_only", False),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
"""Symlink-safe staging for ``LocalDir`` manifest uploads.
|
||||
|
||||
The sandbox SDK's ``LocalDir`` walker refuses to copy symlinks at all — it
|
||||
raises ``LocalDirReadError(reason="symlink_not_supported")`` on the first one
|
||||
as a path-escape / TOCTOU safeguard. Real source trees (especially JS/TS
|
||||
monorepos with workspace or shared-config links) routinely commit symlinks, so
|
||||
handing such a tree straight to ``LocalDir`` aborts the upload before the agent
|
||||
even starts.
|
||||
|
||||
:func:`stage_symlink_safe_dir` returns a path that is always safe to hand to
|
||||
``LocalDir``:
|
||||
|
||||
* a tree with no symlinks is used as-is (no copy);
|
||||
* otherwise the tree is copied into a temp directory with symlinks resolved:
|
||||
|
||||
- a link whose target stays inside the tree is *dereferenced* (its target
|
||||
content is materialized in place), so the agent still sees the file;
|
||||
- a link that escapes the tree, dangles, or forms a cycle is *dropped* and
|
||||
never followed. Refusing to follow out-of-tree links preserves the walker's
|
||||
path-escape safety and keeps host/out-of-tree content from leaking into the
|
||||
(hostile) sandbox.
|
||||
|
||||
Regular files are hard-linked when possible (falling back to a copy across
|
||||
devices), so the staged tree adds negligible disk for the non-symlink bulk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_STAGING_PREFIX = "strix-localdir-"
|
||||
|
||||
|
||||
def _is_within(target: Path, root: Path) -> bool:
|
||||
"""Return whether ``target`` is ``root`` itself or nested under it."""
|
||||
if target == root:
|
||||
return True
|
||||
try:
|
||||
target.relative_to(root)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def tree_has_symlink(root: Path) -> bool:
|
||||
"""Return whether ``root`` contains any symlink (file or directory)."""
|
||||
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
||||
base = Path(dirpath)
|
||||
for name in (*dirnames, *filenames):
|
||||
if (base / name).is_symlink():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _link_or_copy(src: Path, dst: Path) -> None:
|
||||
"""Hard-link ``src`` to ``dst``, falling back to a content copy."""
|
||||
try:
|
||||
os.link(src, dst)
|
||||
except OSError:
|
||||
shutil.copy2(src, dst, follow_symlinks=True)
|
||||
|
||||
|
||||
def _stage_dir(src: Path, dst: Path, root: Path, seen: frozenset[Path]) -> None:
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
for entry in os.scandir(src):
|
||||
entry_path = Path(entry.path)
|
||||
dest_path = dst / entry.name
|
||||
|
||||
if entry.is_symlink():
|
||||
target = Path(os.path.realpath(entry_path))
|
||||
if not _is_within(target, root):
|
||||
logger.warning("staging: dropping out-of-tree symlink %s -> %s", entry_path, target)
|
||||
continue
|
||||
if not target.exists():
|
||||
logger.warning("staging: dropping dangling symlink %s", entry_path)
|
||||
continue
|
||||
if target in seen:
|
||||
logger.warning("staging: dropping cyclic symlink %s -> %s", entry_path, target)
|
||||
continue
|
||||
if target.is_dir():
|
||||
_stage_dir(target, dest_path, root, seen | {target})
|
||||
else:
|
||||
_link_or_copy(target, dest_path)
|
||||
elif entry.is_dir(follow_symlinks=False):
|
||||
_stage_dir(entry_path, dest_path, root, seen)
|
||||
elif entry.is_file(follow_symlinks=False):
|
||||
_link_or_copy(entry_path, dest_path)
|
||||
else:
|
||||
# Sockets, FIFOs, devices — not part of a source tree; skip.
|
||||
logger.debug("staging: skipping non-regular entry %s", entry_path)
|
||||
|
||||
|
||||
def stage_symlink_safe_dir(src_root: Path) -> tuple[Path, Path | None]:
|
||||
"""Return ``(upload_path, staged_temp)`` for uploading ``src_root``.
|
||||
|
||||
``upload_path`` is safe to hand to ``LocalDir``. When the tree contains no
|
||||
symlinks it is ``src_root`` itself and ``staged_temp`` is ``None``.
|
||||
Otherwise a symlink-safe copy is materialized in a temp directory and both
|
||||
returned values point at it; the caller owns removing ``staged_temp`` once
|
||||
the upload completes.
|
||||
"""
|
||||
root = src_root.resolve()
|
||||
if not tree_has_symlink(root):
|
||||
return root, None
|
||||
|
||||
staged = Path(tempfile.mkdtemp(prefix=_STAGING_PREFIX)).resolve()
|
||||
try:
|
||||
_stage_dir(root, staged, root, frozenset({root}))
|
||||
except OSError:
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
raise
|
||||
logger.info("staging: materialized symlink-safe copy of %s at %s", root, staged)
|
||||
return staged, staged
|
||||
@@ -3,17 +3,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agents.sandbox.entries import BaseEntry, LocalDir
|
||||
from agents.sandbox.manifest import Environment, Manifest
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.runtime.backends import get_backend
|
||||
from strix.runtime.backends import backend_supports_bind_mounts, get_backend
|
||||
from strix.runtime.caido_bootstrap import bootstrap_caido
|
||||
from strix.runtime.local_dir_staging import stage_symlink_safe_dir
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from strix.runtime.status import StatusSink
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -28,43 +32,72 @@ _SESSION_CACHE: dict[str, dict[str, Any]] = {}
|
||||
# Manifest root inside the container; entry keys hang off this path.
|
||||
_WORKSPACE_ROOT = "/workspace"
|
||||
|
||||
_PROTECTED_METADATA_NAMES = (".git", ".agents", ".codex")
|
||||
|
||||
def build_session_entries(
|
||||
local_sources: list[dict[str, Any]],
|
||||
) -> tuple[dict[str | Path, BaseEntry], list[dict[str, Any]], list[Path]]:
|
||||
"""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. Trees containing
|
||||
symlinks (which the SDK's ``LocalDir`` walker refuses outright) are first
|
||||
staged into a symlink-safe temp copy; those temp dirs are returned so the
|
||||
caller can remove them once the upload completes.
|
||||
"""
|
||||
entries: dict[str | Path, BaseEntry] = {}
|
||||
def _host_identity_env() -> dict[str, str]:
|
||||
if sys.platform != "linux":
|
||||
return {}
|
||||
return {"STRIX_HOST_UID": str(os.getuid()), "STRIX_HOST_GID": str(os.getgid())}
|
||||
|
||||
|
||||
def build_bind_mounts(local_sources: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
bind_mounts: list[dict[str, Any]] = []
|
||||
staged_dirs: list[Path] = []
|
||||
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,
|
||||
}
|
||||
target = f"{_WORKSPACE_ROOT}/{ws_subdir}"
|
||||
bind_mounts.append({"source": str(resolved), "target": target, "read_only": False})
|
||||
if src.get("protect_metadata"):
|
||||
bind_mounts.extend(_metadata_mounts(resolved, target))
|
||||
return bind_mounts
|
||||
|
||||
|
||||
def build_manifest_entries(local_sources: list[dict[str, Any]]) -> dict[str | Path, BaseEntry]:
|
||||
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())
|
||||
return entries
|
||||
|
||||
|
||||
def _metadata_mounts(tree: Path, target: str) -> list[dict[str, Any]]:
|
||||
mounts: list[dict[str, Any]] = []
|
||||
for name in _PROTECTED_METADATA_NAMES:
|
||||
metadata = tree / name
|
||||
if not metadata.is_dir() and not metadata.is_file():
|
||||
continue
|
||||
if not metadata.resolve().is_relative_to(tree):
|
||||
continue
|
||||
mounts.append({"source": str(metadata), "target": f"{target}/{name}", "read_only": True})
|
||||
gitdir = _gitdir_from_pointer(metadata) if metadata.is_file() else None
|
||||
if gitdir is not None and gitdir.exists() and gitdir.is_relative_to(tree):
|
||||
relative = gitdir.relative_to(tree).as_posix()
|
||||
mounts.append(
|
||||
{"source": str(gitdir), "target": f"{target}/{relative}", "read_only": True}
|
||||
)
|
||||
else:
|
||||
upload_path, staged = stage_symlink_safe_dir(resolved)
|
||||
if staged is not None:
|
||||
staged_dirs.append(staged)
|
||||
entries[ws_subdir] = LocalDir(src=upload_path)
|
||||
return entries, bind_mounts, staged_dirs
|
||||
return mounts
|
||||
|
||||
|
||||
def _gitdir_from_pointer(git_file: Path) -> Path | None:
|
||||
try:
|
||||
content = git_file.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return None
|
||||
for line in content.splitlines():
|
||||
prefix, _, value = line.partition(":")
|
||||
if prefix.strip() == "gitdir" and value.strip():
|
||||
candidate = Path(value.strip()).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
candidate = git_file.parent / candidate
|
||||
return candidate.resolve()
|
||||
return None
|
||||
|
||||
|
||||
async def create_or_reuse(
|
||||
@@ -72,19 +105,32 @@ async def create_or_reuse(
|
||||
*,
|
||||
image: str,
|
||||
local_sources: list[dict[str, Any]],
|
||||
status_sink: StatusSink | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the existing session bundle for ``scan_id`` or create a new one.
|
||||
|
||||
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``.
|
||||
``/workspace/<workspace_subdir>`` inside the container.
|
||||
"""
|
||||
|
||||
def report(phase: str) -> None:
|
||||
if status_sink is not None:
|
||||
status_sink(phase)
|
||||
|
||||
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, bind_mounts, staged_dirs = build_session_entries(local_sources)
|
||||
backend_name = load_settings().runtime.backend
|
||||
backend = get_backend(backend_name)
|
||||
|
||||
if backend_supports_bind_mounts(backend_name):
|
||||
bind_mounts = build_bind_mounts(local_sources)
|
||||
entries: dict[str | Path, BaseEntry] = {}
|
||||
else:
|
||||
bind_mounts = []
|
||||
entries = build_manifest_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.)
|
||||
@@ -98,6 +144,7 @@ async def create_or_reuse(
|
||||
value={
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
"HOST_GATEWAY": "host.docker.internal",
|
||||
**_host_identity_env(),
|
||||
"http_proxy": container_caido_url,
|
||||
"https_proxy": container_caido_url,
|
||||
"ALL_PROXY": container_caido_url,
|
||||
@@ -106,26 +153,21 @@ async def create_or_reuse(
|
||||
),
|
||||
)
|
||||
|
||||
backend_name = load_settings().runtime.backend
|
||||
backend = get_backend(backend_name)
|
||||
|
||||
logger.info(
|
||||
"Creating sandbox session for scan %s (backend=%s, image=%s)",
|
||||
scan_id,
|
||||
backend_name,
|
||||
image,
|
||||
)
|
||||
try:
|
||||
client, session = await backend(
|
||||
image=image,
|
||||
manifest=manifest,
|
||||
exposed_ports=(_CONTAINER_CAIDO_PORT,),
|
||||
bind_mounts=bind_mounts,
|
||||
)
|
||||
finally:
|
||||
for staged in staged_dirs:
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
report("Starting sandbox container")
|
||||
client, session = await backend(
|
||||
image=image,
|
||||
manifest=manifest,
|
||||
exposed_ports=(_CONTAINER_CAIDO_PORT,),
|
||||
bind_mounts=bind_mounts,
|
||||
)
|
||||
|
||||
report("Setting up the proxy")
|
||||
caido_endpoint = await session.resolve_exposed_port(_CONTAINER_CAIDO_PORT)
|
||||
scheme = "https" if caido_endpoint.tls else "http"
|
||||
host_caido_url = f"{scheme}://{caido_endpoint.host}:{caido_endpoint.port}"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Startup phase reporting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
StatusSink = Callable[[str], None]
|
||||
Reference in New Issue
Block a user