Skip to content

Commit d88b3dd

Browse files
committed
Add docker sandbox integration
1 parent fce8aa6 commit d88b3dd

File tree

9 files changed

+240
-12
lines changed

9 files changed

+240
-12
lines changed

libs/deepagents-cli/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ deepagents --agent mybot
4747
deepagents --auto-approve
4848

4949
# Execute code in a remote sandbox
50-
deepagents --sandbox modal # or runloop, daytona
50+
deepagents --sandbox modal # or runloop, daytona, docker
5151
deepagents --sandbox-id dbx_123 # reuse existing sandbox
5252
```
5353

libs/deepagents-cli/deepagents_cli/agent.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def get_system_prompt(assistant_id: str, sandbox_type: str | None = None) -> str
9696
9797
Args:
9898
assistant_id: The agent identifier for path references
99-
sandbox_type: Type of sandbox provider ("modal", "runloop", "daytona").
99+
sandbox_type: Type of sandbox provider ("modal", "runloop", "daytona", "docker").
100100
If None, agent is operating in local mode.
101101
102102
Returns:
@@ -339,7 +339,7 @@ def create_agent_with_config(
339339
tools: Additional tools to provide to agent
340340
sandbox: Optional sandbox backend for remote execution (e.g., ModalBackend).
341341
If None, uses local filesystem + shell.
342-
sandbox_type: Type of sandbox provider ("modal", "runloop", "daytona")
342+
sandbox_type: Type of sandbox provider ("modal", "runloop", "daytona", "docker")
343343
344344
Returns:
345345
2-tuple of graph and backend
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Docker sandbox backend implementation."""
2+
3+
from __future__ import annotations
4+
5+
from deepagents.backends.protocol import (
6+
ExecuteResponse,
7+
FileDownloadResponse,
8+
FileUploadResponse,
9+
)
10+
from deepagents.backends.sandbox import BaseSandbox
11+
12+
import io
13+
import tarfile
14+
15+
16+
class DockerBackend(BaseSandbox):
17+
"""Docker backend implementation conforming to SandboxBackendProtocol.
18+
19+
This implementation inherits all file operation methods from BaseSandbox
20+
and only implements the execute() method using Docker SDK.
21+
"""
22+
23+
def __init__(self, sandbox: Sandbox) -> None:
24+
"""Initialize the DockerBackend with a Docker sandbox client.
25+
26+
Args:
27+
sandbox: Docker sandbox instance
28+
"""
29+
self._sandbox = sandbox
30+
self._timeout: int = 30 * 60 # 30 mins
31+
32+
@property
33+
def id(self) -> str:
34+
"""Unique identifier for the sandbox backend."""
35+
return self._sandbox.id
36+
37+
def execute(
38+
self,
39+
command: str,
40+
) -> ExecuteResponse:
41+
"""Execute a command in the sandbox and return ExecuteResponse.
42+
43+
Args:
44+
command: Full shell command string to execute.
45+
46+
Returns:
47+
ExecuteResponse with combined output, exit code, optional signal, and truncation flag.
48+
"""
49+
result = self._sandbox.exec_run(cmd=command, user="root", workdir="/root")
50+
51+
output = result.output.decode('utf-8', errors='replace') if result.output else ""
52+
exit_code = result.exit_code
53+
54+
return ExecuteResponse(
55+
output=output,
56+
exit_code=exit_code,
57+
truncated=False,
58+
)
59+
60+
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
61+
"""Download multiple files from the Docker sandbox.
62+
63+
Leverages Docker's get_archive functionality.
64+
65+
Args:
66+
paths: List of file paths to download.
67+
68+
Returns:
69+
List of FileDownloadResponse objects, one per input path.
70+
Response order matches input order.
71+
"""
72+
73+
# Download files using Docker's get_archive
74+
responses = []
75+
try:
76+
for path in paths:
77+
strm, stat = self._sandbox.get_archive(path)
78+
file_like_object = io.BytesIO(b"".join(chunk for chunk in strm))
79+
print("Before tar")
80+
with tarfile.open(fileobj=file_like_object, mode='r') as tar:
81+
print(f"{tar.getnames()}")
82+
with tar.extractfile(stat['name']) as f:
83+
content = f.read()
84+
responses.append(FileDownloadResponse(path=path, content=content, error=None))
85+
except Exception as e:
86+
pass
87+
88+
return responses
89+
90+
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
91+
"""Upload multiple files to the Docker sandbox.
92+
93+
Leverages Docker's put_archiv functionality.
94+
95+
Args:
96+
files: List of (path, content) tuples to upload.
97+
98+
Returns:
99+
List of FileUploadResponse objects, one per input file.
100+
Response order matches input order.
101+
"""
102+
103+
for path, content in files:
104+
pw_tarstream = io.BytesIO()
105+
with tarfile.TarFile(fileobj=pw_tarstream, mode='w') as tar:
106+
data_size = len(content)
107+
data_io = io.BytesIO(content)
108+
info = tarfile.TarInfo(path)
109+
info.size = data_size
110+
tar.addfile(info, data_io)
111+
self._sandbox.put_archive(path, pw_tarstream)
112+
113+
return [FileUploadResponse(path=path, error=None) for path, _ in files]

libs/deepagents-cli/deepagents_cli/integrations/sandbox_factory.py

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -266,10 +266,78 @@ def create_daytona_sandbox(
266266
console.print(f"[yellow]⚠ Cleanup failed: {e}[/yellow]")
267267

268268

269+
@contextmanager
270+
def create_docker_sandbox(
271+
*, sandbox_id: str | None = None, setup_script_path: str | None = None
272+
) -> Generator[SandboxBackendProtocol, None, None]:
273+
"""Create or connect to Docker sandbox.
274+
275+
Args:
276+
sandbox_id: Optional existing sandbox ID to reuse
277+
setup_script_path: Optional path to setup script to run after sandbox starts
278+
279+
Yields:
280+
(DockerBackend, sandbox_id)
281+
282+
Raises:
283+
ImportError: Docker SDK not installed
284+
Exception: Sandbox creation/connection failed
285+
FileNotFoundError: Setup script not found
286+
RuntimeError: Setup script failed
287+
"""
288+
import docker
289+
290+
from deepagents_cli.integrations.docker import DockerBackend
291+
292+
console.print("[yellow]Starting Docker sandbox...[/yellow]")
293+
294+
# Create ephemeral app (auto-cleans up on exit)
295+
client = docker.from_env()
296+
297+
container = client.containers.run(
298+
"python:3.12-slim",
299+
command="tail -f /dev/null", # Keep container running
300+
detach=True,
301+
tty=True,
302+
mem_limit="512m",
303+
cpu_quota=50000, # Limits CPU usage (e.g., 50% of one core)
304+
pids_limit=100, # Limit number of processes
305+
# Temporarily allow network and root access for setup
306+
network_mode="bridge",
307+
# No user restriction for install step
308+
read_only=False, # Temporarily allow writes
309+
tmpfs={"/tmp": "rw,size=64m,noexec,nodev,nosuid"}, # Writable /tmp
310+
)
311+
sandbox_id = container.id
312+
313+
backend = DockerBackend(container)
314+
console.print(f"[green]✓ Docker sandbox ready: {backend.id}[/green]")
315+
316+
# Run setup script if provided
317+
if setup_script_path:
318+
_run_sandbox_setup(backend, setup_script_path)
319+
try:
320+
yield backend
321+
finally:
322+
try:
323+
console.print(f"[dim]Terminating Docker sandbox {sandbox_id}...[/dim]")
324+
try:
325+
container.stop(timeout=5)
326+
container.remove(force=True)
327+
except docker.errors.NotFound:
328+
print(f"Container {sandbox_id} already removed.")
329+
except docker.errors.APIError as e:
330+
print(f"Error during container cleanup {sandbox_id}: {e}")
331+
console.print(f"[dim]✓ Docker sandbox {sandbox_id} terminated[/dim]")
332+
except Exception as e:
333+
console.print(f"[yellow]⚠ Cleanup failed: {e}[/yellow]")
334+
335+
269336
_PROVIDER_TO_WORKING_DIR = {
270337
"modal": "/workspace",
271338
"runloop": "/home/user",
272339
"daytona": "/home/daytona",
340+
"docker": "/root",
273341
}
274342

275343

@@ -278,6 +346,7 @@ def create_daytona_sandbox(
278346
"modal": create_modal_sandbox,
279347
"runloop": create_runloop_sandbox,
280348
"daytona": create_daytona_sandbox,
349+
"docker": create_docker_sandbox,
281350
}
282351

283352

@@ -294,7 +363,7 @@ def create_sandbox(
294363
the appropriate provider-specific context manager.
295364
296365
Args:
297-
provider: Sandbox provider ("modal", "runloop", "daytona")
366+
provider: Sandbox provider ("modal", "runloop", "daytona", "docker")
298367
sandbox_id: Optional existing sandbox ID to reuse
299368
setup_script_path: Optional path to setup script to run after sandbox starts
300369
@@ -318,7 +387,7 @@ def get_available_sandbox_types() -> list[str]:
318387
"""Get list of available sandbox provider types.
319388
320389
Returns:
321-
List of sandbox type names (e.g., ["modal", "runloop", "daytona"])
390+
List of sandbox type names (e.g., ["modal", "runloop", "daytona", "docker"])
322391
"""
323392
return list(_SANDBOX_PROVIDERS.keys())
324393

@@ -327,7 +396,7 @@ def get_default_working_dir(provider: str) -> str:
327396
"""Get the default working directory for a given sandbox provider.
328397
329398
Args:
330-
provider: Sandbox provider name ("modal", "runloop", "daytona")
399+
provider: Sandbox provider name ("modal", "runloop", "daytona", "docker")
331400
332401
Returns:
333402
Default working directory path as string

libs/deepagents-cli/deepagents_cli/main.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def parse_args():
109109
)
110110
parser.add_argument(
111111
"--sandbox",
112-
choices=["none", "modal", "daytona", "runloop"],
112+
choices=["none", "modal", "daytona", "runloop", "docker"],
113113
default="none",
114114
help="Remote sandbox for code execution (default: none - local only)",
115115
)
@@ -144,7 +144,7 @@ async def simple_cli(
144144
145145
Args:
146146
backend: Backend for file operations (CompositeBackend)
147-
sandbox_type: Type of sandbox being used (e.g., "modal", "runloop", "daytona").
147+
sandbox_type: Type of sandbox being used (e.g., "modal", "runloop", "daytona", "docker").
148148
If None, running in local mode.
149149
sandbox_id: ID of the active sandbox
150150
setup_script_path: Path to setup script that was run (if any)
@@ -329,7 +329,7 @@ async def main(
329329
Args:
330330
assistant_id: Agent identifier for memory storage
331331
session_state: Session state with auto-approve settings
332-
sandbox_type: Type of sandbox ("none", "modal", "runloop", "daytona")
332+
sandbox_type: Type of sandbox ("none", "modal", "runloop", "daytona", "docker")
333333
sandbox_id: Optional existing sandbox ID to reuse
334334
setup_script_path: Optional path to setup script to run in sandbox
335335
"""

libs/deepagents-cli/deepagents_cli/ui.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,7 @@ def show_help() -> None:
556556
console.print(" --agent NAME Agent identifier (default: agent)")
557557
console.print(" --auto-approve Auto-approve tool usage without prompting")
558558
console.print(
559-
" --sandbox TYPE Remote sandbox for execution (modal, runloop, daytona)"
559+
" --sandbox TYPE Remote sandbox for execution (modal, runloop, daytona, docker)"
560560
)
561561
console.print(" --sandbox-id ID Reuse existing sandbox (skips creation/cleanup)")
562562
console.print()

libs/deepagents-cli/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ dependencies = [
1717
"modal>=0.65.0",
1818
"markdownify>=0.13.0",
1919
"langchain>=1.0.7",
20+
"docker>=7.1.0",
2021
]
2122

2223
[project.scripts]

libs/deepagents-cli/tests/integration_tests/test_sandbox_factory.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Test sandbox integrations with upload/download functionality.
22
3-
This module tests sandbox backends (RunLoop, Daytona, Modal) with support for
3+
This module tests sandbox backends (RunLoop, Daytona, Modal, Docker) with support for
44
optional sandbox reuse to reduce test execution time.
55
66
Set REUSE_SANDBOX=1 environment variable to reuse sandboxes across tests within
@@ -320,3 +320,13 @@ def sandbox(self) -> Iterator[BaseSandbox]:
320320
"""Provide a Modal sandbox instance."""
321321
with create_sandbox("modal") as sandbox:
322322
yield sandbox
323+
324+
325+
# class TestDockerIntegration(BaseSandboxIntegrationTest):
326+
# """Test Docker backend integration."""
327+
328+
# @pytest.fixture(scope="class")
329+
# def sandbox(self) -> Iterator[BaseSandbox]:
330+
# """Provide a Docker sandbox instance."""
331+
# with create_sandbox("docker") as sandbox:
332+
# yield sandbox

libs/deepagents-cli/uv.lock

Lines changed: 36 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)