feat: Remove MCP specific code
This commit is contained in:
45
README.md
45
README.md
@@ -4,11 +4,9 @@ A Docker-based sandbox and LangChain tool set for LLM agents that need a safe, i
|
||||
|
||||
## 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`.
|
||||
|
||||
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
|
||||
|
||||
- Python ≥ 3.11
|
||||
@@ -37,18 +35,29 @@ docker-agent-sandbox = { path = "../docker-agent-sandbox" }
|
||||
from docker_agent_sandbox import DockerSandbox
|
||||
|
||||
sandbox = DockerSandbox(
|
||||
sandbox_dir="/tmp/my-sandbox", # bind-mounted into the container
|
||||
container_name="my-agent-run",
|
||||
container_workdir="/workspace", # path inside the container
|
||||
image="my-agent-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.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
|
||||
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")
|
||||
```
|
||||
|
||||
### 3. Bind tools to a LangChain agent
|
||||
### 4. Bind tools to a LangChain agent
|
||||
|
||||
```python
|
||||
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.
|
||||
```
|
||||
|
||||
### 4. Tear down
|
||||
### 5. Tear down
|
||||
|
||||
```python
|
||||
sandbox.stop()
|
||||
@@ -74,24 +83,30 @@ sandbox.stop()
|
||||
|
||||
## 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 |
|
||||
|---|---|---|
|
||||
| `sandbox_dir` | — | Host directory bind-mounted into the container |
|
||||
| `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 |
|
||||
| `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
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `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)` |
|
||||
| `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_search_files_tool(sandbox)` | `search_files` | `find` by name glob |
|
||||
| `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.
|
||||
|
||||
@@ -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""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user