feat: Remove MCP specific code
Some checks failed
CI / test (push) Failing after 51s
CI / publish (push) Has been skipped

This commit is contained in:
2026-04-02 13:24:07 +02:00
parent 1926c0311e
commit cd51258fb5
2 changed files with 93 additions and 85 deletions

View File

@@ -4,9 +4,7 @@ from __future__ import annotations
import concurrent.futures
import io
import socket
import tarfile
import time
from pathlib import Path
from typing import TYPE_CHECKING
@@ -20,30 +18,37 @@ if TYPE_CHECKING:
class DockerSandbox:
"""
Manages a single long-running Docker container used as the bash execution
environment for an LLM agent.
Manages a single long-running Docker container.
The sandbox directory is bind-mounted at *container_workdir* inside the
container (default ``/workspace``), giving the model a stable, short path
regardless of where the sandbox lives on the host.
All container configuration (volumes, ports, environment variables,
capabilities) is supplied by the caller.
"""
def __init__(
self,
sandbox_dir: str,
container_name: str,
container_workdir: str = "/workspace",
pin_mcp_port: int = 8080,
image: str = "docker-agent-sandbox",
dockerfile_dir: str | None = None,
volumes: dict | None = None,
ports: dict | None = None,
environment: dict[str, str] | None = None,
working_dir: str | None = None,
network_mode: str = "bridge",
cap_drop: list[str] | None = None,
cap_add: list[str] | None = None,
security_opt: list[str] | None = None,
) -> None:
self.sandbox_dir = sandbox_dir
self.container_name = container_name
self.container_workdir = container_workdir
self.pin_mcp_port = pin_mcp_port
self.mcp_url: str = ""
self._image = image
self._dockerfile_dir = dockerfile_dir
self._volumes = volumes or {}
self._ports = ports or {}
self._environment = environment or {}
self._working_dir = working_dir
self._network_mode = network_mode
self._cap_drop = cap_drop
self._cap_add = cap_add
self._security_opt = security_opt
self._client: docker.DockerClient = docker.from_env()
self._container: docker.models.containers.Container | None = None
@@ -61,9 +66,7 @@ class DockerSandbox:
pass
if self._dockerfile_dir is None:
raise ValueError(
"dockerfile_dir must be provided to build the image"
)
raise ValueError("dockerfile_dir must be provided to build the image")
logger.info("Building image {!r} from {}", self._image, self._dockerfile_dir)
_, logs = self._client.images.build(
@@ -80,74 +83,71 @@ class DockerSandbox:
def start(self) -> None:
"""
Start the sandbox container.
Start the container.
Any existing container with the same name is removed first so that
re-running the agent always starts from a clean state.
re-runs always start from a clean state.
"""
# Remove any leftover container from a previous run.
try:
old = self._client.containers.get(self.container_name)
old.remove(force=True)
except docker.errors.NotFound:
pass
self._container = self._client.containers.run(
self._image,
run_kwargs: dict = dict(
name=self.container_name,
detach=True,
volumes={
self.sandbox_dir: {"bind": self.container_workdir, "mode": "rw,Z"}
},
working_dir=self.container_workdir,
environment={
"CONTAINER_WORKSPACE": self.container_workdir,
"PIN_MCP_PORT": str(self.pin_mcp_port),
},
# Expose pin-mcp port; Docker assigns a random host port.
ports={f"{self.pin_mcp_port}/tcp": None},
# No outbound network needed; all tools are pre-installed.
network_mode="bridge",
# Minimal capability set; SYS_PTRACE is required for ltrace/strace/gdb.
cap_drop=["ALL"],
cap_add=["SYS_PTRACE"],
security_opt=["no-new-privileges"],
volumes=self._volumes,
environment=self._environment,
ports=self._ports,
network_mode=self._network_mode,
)
if self._working_dir is not None:
run_kwargs["working_dir"] = self._working_dir
if self._cap_drop is not None:
run_kwargs["cap_drop"] = self._cap_drop
if self._cap_add is not None:
run_kwargs["cap_add"] = self._cap_add
if self._security_opt is not None:
run_kwargs["security_opt"] = self._security_opt
# Resolve the host port Docker assigned and wait for pin-mcp to be ready.
self._container = self._client.containers.run(self._image, **run_kwargs)
self._container.reload()
host_port = self._container.ports[f"{self.pin_mcp_port}/tcp"][0]["HostPort"]
self.mcp_url = f"http://localhost:{host_port}/mcp"
self._wait_for_mcp()
logger.info(
"Container {!r} started (id={}), pin-mcp at {}.",
"Container {!r} started (id={}).",
self.container_name,
self._container.short_id,
self.mcp_url,
)
def _wait_for_mcp(self, timeout: int = 30) -> None:
"""Block until pin-mcp's TCP port accepts connections, or raise on timeout."""
host_port = int(self.mcp_url.split(":")[-1].split("/")[0])
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with socket.create_connection(("localhost", host_port), timeout=1):
return
except OSError:
time.sleep(0.5)
raise RuntimeError(
f"pin-mcp did not become ready on port {host_port} within {timeout}s"
)
def stop(self) -> None:
"""Remove the sandbox container."""
"""Remove the container."""
if self._container is not None:
self._container.remove(force=True)
logger.info("Container {!r} stopped.", self.container_name)
self._container = None
# ------------------------------------------------------------------
# Port inspection
# ------------------------------------------------------------------
def get_host_port(self, container_port: str) -> str:
"""
Return the host port Docker mapped to *container_port*.
*container_port* should include the protocol, e.g. ``"8080/tcp"``.
Raises ``RuntimeError`` if the container is not running or the port
is not exposed.
"""
if self._container is None:
raise RuntimeError("Sandbox container is not running.")
mappings = self._container.ports.get(container_port)
if not mappings:
raise RuntimeError(
f"Port {container_port!r} is not mapped on container {self.container_name!r}"
)
return mappings[0]["HostPort"]
# ------------------------------------------------------------------
# File I/O
# ------------------------------------------------------------------
@@ -156,10 +156,8 @@ class DockerSandbox:
"""
Write *content* to *path* inside the container using ``put_archive``.
Using the archive API avoids all shell-escaping concerns; any text
(including content with quotes, backslashes, or null bytes) is
transferred safely as a tar stream. Parent directories are created
automatically via a preceding ``mkdir -p``.
The tar archive API avoids all shell-escaping concerns. Parent
directories are created automatically via a preceding ``mkdir -p``.
"""
if self._container is None:
raise RuntimeError("Sandbox container is not running.")
@@ -167,10 +165,8 @@ class DockerSandbox:
p = Path(path)
encoded = content.encode("utf-8")
# Ensure the parent directory exists inside the container.
self._container.exec_run(["mkdir", "-p", str(p.parent)])
# Pack the file into an in-memory tar archive and push it in.
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as tar:
info = tarfile.TarInfo(name=p.name)
@@ -196,10 +192,11 @@ class DockerSandbox:
return 1, "Sandbox container is not running."
def _run() -> tuple[int, bytes]:
kwargs: dict = dict(demux=False)
if self._working_dir is not None:
kwargs["workdir"] = self._working_dir
exit_code, output = self._container.exec_run(
["bash", "-c", command],
workdir=self.container_workdir,
demux=False,
["bash", "-c", command], **kwargs
)
return exit_code, output or b""