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,11 +4,9 @@ A Docker-based sandbox and LangChain tool set for LLM agents that need a safe, i
## What it provides ## What it provides
- **`DockerSandbox`** — manages a single long-running Docker container: lifecycle (`start`/`stop`), file writes via the tar archive API, and command execution with timeout enforcement. - **`DockerSandbox`** — manages a single long-running Docker container: lifecycle (`start`/`stop`), file writes via the tar archive API, and command execution with timeout enforcement. Volumes, ports, environment variables, and capabilities are all caller-supplied.
- **LangChain tools** — a ready-to-use set of tools that expose the sandbox to an agent: `bash`, `read_file`, `write_file`, `edit_file`, `list_dir`, `delete_file`, `move_file`, `copy_file`, `make_dir`, `search_files`, `grep`. - **LangChain tools** — a ready-to-use set of tools that expose the sandbox to an agent: `bash`, `read_file`, `write_file`, `edit_file`, `list_dir`, `delete_file`, `move_file`, `copy_file`, `make_dir`, `search_files`, `grep`.
The sandbox directory is bind-mounted inside the container, so the agent works with stable paths regardless of where the sandbox lives on the host.
## Requirements ## Requirements
- Python ≥ 3.11 - Python ≥ 3.11
@@ -37,18 +35,29 @@ docker-agent-sandbox = { path = "../docker-agent-sandbox" }
from docker_agent_sandbox import DockerSandbox from docker_agent_sandbox import DockerSandbox
sandbox = DockerSandbox( sandbox = DockerSandbox(
sandbox_dir="/tmp/my-sandbox", # bind-mounted into the container
container_name="my-agent-run", container_name="my-agent-run",
container_workdir="/workspace", # path inside the container
image="my-agent-image", image="my-agent-image",
dockerfile_dir="/path/to/dockerfile", # only needed if building the image dockerfile_dir="/path/to/dockerfile", # only needed if building the image
volumes={"/host/sandbox": {"bind": "/workspace", "mode": "rw"}},
ports={"8080/tcp": None}, # Docker assigns a random host port
environment={"MY_VAR": "value"},
working_dir="/workspace",
cap_drop=["ALL"],
cap_add=["SYS_PTRACE"],
security_opt=["no-new-privileges"],
) )
sandbox.build_image_if_missing() sandbox.build_image_if_missing()
sandbox.start() sandbox.start()
``` ```
### 2. Run commands and write files ### 2. Inspect mapped ports
```python
host_port = sandbox.get_host_port("8080/tcp")
```
### 3. Run commands and write files
```python ```python
exit_code, output = sandbox.exec("ls -la") exit_code, output = sandbox.exec("ls -la")
@@ -56,7 +65,7 @@ sandbox.write_file("/workspace/hello.py", "print('hello')")
exit_code, output = sandbox.exec("python /workspace/hello.py") exit_code, output = sandbox.exec("python /workspace/hello.py")
``` ```
### 3. Bind tools to a LangChain agent ### 4. Bind tools to a LangChain agent
```python ```python
from docker_agent_sandbox import make_bash_tool, make_file_ops_tools from docker_agent_sandbox import make_bash_tool, make_file_ops_tools
@@ -66,7 +75,7 @@ tools = make_file_ops_tools(sandbox) + [make_bash_tool(sandbox)]
# Pass `tools` to your LangChain / LangGraph agent as usual. # Pass `tools` to your LangChain / LangGraph agent as usual.
``` ```
### 4. Tear down ### 5. Tear down
```python ```python
sandbox.stop() sandbox.stop()
@@ -74,24 +83,30 @@ sandbox.stop()
## API reference ## API reference
### `DockerSandbox(sandbox_dir, container_name, container_workdir, pin_mcp_port, image, dockerfile_dir)` ### `DockerSandbox(container_name, image, dockerfile_dir, volumes, ports, environment, working_dir, network_mode, cap_drop, cap_add, security_opt)`
| Parameter | Default | Description | | Parameter | Default | Description |
|---|---|---| |---|---|---|
| `sandbox_dir` | — | Host directory bind-mounted into the container |
| `container_name` | — | Docker container name | | `container_name` | — | Docker container name |
| `container_workdir` | `"/workspace"` | Working directory inside the container |
| `pin_mcp_port` | `8080` | Port exposed by a sidecar MCP server (if any) |
| `image` | `"docker-agent-sandbox"` | Docker image tag to run | | `image` | `"docker-agent-sandbox"` | Docker image tag to run |
| `dockerfile_dir` | `None` | Directory containing the `Dockerfile`; required only if calling `build_image_if_missing` | | `dockerfile_dir` | `None` | Directory containing the `Dockerfile`; required only if calling `build_image_if_missing` |
| `volumes` | `{}` | Volume bindings passed directly to Docker (same format as `docker-py`) |
| `ports` | `{}` | Port bindings passed directly to Docker, e.g. `{"8080/tcp": None}` for a random host port |
| `environment` | `{}` | Environment variables injected into the container |
| `working_dir` | `None` | Default working directory for `exec` calls (uses image `WORKDIR` if omitted) |
| `network_mode` | `"bridge"` | Docker network mode |
| `cap_drop` | `None` | Linux capabilities to drop |
| `cap_add` | `None` | Linux capabilities to add |
| `security_opt` | `None` | Docker security options |
#### Methods #### Methods
| Method | Description | | Method | Description |
|---|---| |---|---|
| `build_image_if_missing()` | Builds the image from `dockerfile_dir` if not already present locally | | `build_image_if_missing()` | Builds the image from `dockerfile_dir` if not already present locally |
| `start()` | Removes any existing container with the same name, starts a fresh one, and waits for the MCP port | | `start()` | Removes any existing container with the same name and starts a fresh one |
| `stop()` | Removes the container | | `stop()` | Removes the container |
| `get_host_port(container_port)` | Returns the host port Docker mapped to `container_port` (e.g. `"8080/tcp"`) |
| `exec(command, timeout=120)` | Runs a bash command; returns `(exit_code, stdout+stderr)` | | `exec(command, timeout=120)` | Runs a bash command; returns `(exit_code, stdout+stderr)` |
| `write_file(path, content)` | Writes a UTF-8 file inside the container using the tar archive API | | `write_file(path, content)` | Writes a UTF-8 file inside the container using the tar archive API |
@@ -111,7 +126,3 @@ sandbox.stop()
| `make_make_dir_tool(sandbox)` | `make_dir` | `mkdir -p` | | `make_make_dir_tool(sandbox)` | `make_dir` | `mkdir -p` |
| `make_search_files_tool(sandbox)` | `search_files` | `find` by name glob | | `make_search_files_tool(sandbox)` | `search_files` | `find` by name glob |
| `make_grep_tool(sandbox)` | `grep` | Extended-regex search with optional recursion | | `make_grep_tool(sandbox)` | `grep` | Extended-regex search with optional recursion |
## Security notes
Containers are started with `cap_drop=ALL`, `cap_add=SYS_PTRACE` (needed for debuggers/tracers), and `no-new-privileges`. Network mode is `bridge` with no explicit outbound rules — restrict further via Docker network policies if needed.

View File

@@ -4,9 +4,7 @@ from __future__ import annotations
import concurrent.futures import concurrent.futures
import io import io
import socket
import tarfile import tarfile
import time
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -20,30 +18,37 @@ if TYPE_CHECKING:
class DockerSandbox: class DockerSandbox:
""" """
Manages a single long-running Docker container used as the bash execution Manages a single long-running Docker container.
environment for an LLM agent.
The sandbox directory is bind-mounted at *container_workdir* inside the All container configuration (volumes, ports, environment variables,
container (default ``/workspace``), giving the model a stable, short path capabilities) is supplied by the caller.
regardless of where the sandbox lives on the host.
""" """
def __init__( def __init__(
self, self,
sandbox_dir: str,
container_name: str, container_name: str,
container_workdir: str = "/workspace",
pin_mcp_port: int = 8080,
image: str = "docker-agent-sandbox", image: str = "docker-agent-sandbox",
dockerfile_dir: str | None = None, 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: ) -> None:
self.sandbox_dir = sandbox_dir
self.container_name = container_name 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._image = image
self._dockerfile_dir = dockerfile_dir 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._client: docker.DockerClient = docker.from_env()
self._container: docker.models.containers.Container | None = None self._container: docker.models.containers.Container | None = None
@@ -61,9 +66,7 @@ class DockerSandbox:
pass pass
if self._dockerfile_dir is None: if self._dockerfile_dir is None:
raise ValueError( raise ValueError("dockerfile_dir must be provided to build the image")
"dockerfile_dir must be provided to build the image"
)
logger.info("Building image {!r} from {}", self._image, self._dockerfile_dir) logger.info("Building image {!r} from {}", self._image, self._dockerfile_dir)
_, logs = self._client.images.build( _, logs = self._client.images.build(
@@ -80,74 +83,71 @@ class DockerSandbox:
def start(self) -> None: def start(self) -> None:
""" """
Start the sandbox container. Start the container.
Any existing container with the same name is removed first so that 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: try:
old = self._client.containers.get(self.container_name) old = self._client.containers.get(self.container_name)
old.remove(force=True) old.remove(force=True)
except docker.errors.NotFound: except docker.errors.NotFound:
pass pass
self._container = self._client.containers.run( run_kwargs: dict = dict(
self._image,
name=self.container_name, name=self.container_name,
detach=True, detach=True,
volumes={ volumes=self._volumes,
self.sandbox_dir: {"bind": self.container_workdir, "mode": "rw,Z"} environment=self._environment,
}, ports=self._ports,
working_dir=self.container_workdir, network_mode=self._network_mode,
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"],
) )
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() 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( logger.info(
"Container {!r} started (id={}), pin-mcp at {}.", "Container {!r} started (id={}).",
self.container_name, self.container_name,
self._container.short_id, 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: def stop(self) -> None:
"""Remove the sandbox container.""" """Remove the container."""
if self._container is not None: if self._container is not None:
self._container.remove(force=True) self._container.remove(force=True)
logger.info("Container {!r} stopped.", self.container_name) logger.info("Container {!r} stopped.", self.container_name)
self._container = None 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 # File I/O
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -156,10 +156,8 @@ class DockerSandbox:
""" """
Write *content* to *path* inside the container using ``put_archive``. Write *content* to *path* inside the container using ``put_archive``.
Using the archive API avoids all shell-escaping concerns; any text The tar archive API avoids all shell-escaping concerns. Parent
(including content with quotes, backslashes, or null bytes) is directories are created automatically via a preceding ``mkdir -p``.
transferred safely as a tar stream. Parent directories are created
automatically via a preceding ``mkdir -p``.
""" """
if self._container is None: if self._container is None:
raise RuntimeError("Sandbox container is not running.") raise RuntimeError("Sandbox container is not running.")
@@ -167,10 +165,8 @@ class DockerSandbox:
p = Path(path) p = Path(path)
encoded = content.encode("utf-8") encoded = content.encode("utf-8")
# Ensure the parent directory exists inside the container.
self._container.exec_run(["mkdir", "-p", str(p.parent)]) 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() buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as tar: with tarfile.open(fileobj=buf, mode="w") as tar:
info = tarfile.TarInfo(name=p.name) info = tarfile.TarInfo(name=p.name)
@@ -196,10 +192,11 @@ class DockerSandbox:
return 1, "Sandbox container is not running." return 1, "Sandbox container is not running."
def _run() -> tuple[int, bytes]: 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( exit_code, output = self._container.exec_run(
["bash", "-c", command], ["bash", "-c", command], **kwargs
workdir=self.container_workdir,
demux=False,
) )
return exit_code, output or b"" return exit_code, output or b""