deepagents-docker 0.0.2__tar.gz → 0.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,42 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [master, main]
7
+
8
+ jobs:
9
+ lint:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+
14
+ - uses: astral-sh/setup-uv@v5
15
+
16
+ - name: Install dependencies
17
+ run: uv sync
18
+
19
+ - name: Ruff check
20
+ run: uv run ruff check .
21
+
22
+ - name: Ruff format
23
+ run: uv run ruff format --check .
24
+
25
+ test:
26
+ runs-on: ubuntu-latest
27
+ strategy:
28
+ fail-fast: false
29
+ matrix:
30
+ python-version: ["3.12", "3.13"]
31
+ steps:
32
+ - uses: actions/checkout@v4
33
+
34
+ - uses: astral-sh/setup-uv@v5
35
+ with:
36
+ python-version: ${{ matrix.python-version }}
37
+
38
+ - name: Install dependencies
39
+ run: uv sync
40
+
41
+ - name: Run tests
42
+ run: uv run pytest -v
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepagents-docker
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: Docker-backed sandbox backend for DeepAgents
5
5
  Project-URL: Homepage, https://github.com/andybbruno/deepagents-docker
6
6
  Project-URL: Repository, https://github.com/andybbruno/deepagents-docker
@@ -14,13 +14,14 @@ Classifier: License :: OSI Approved :: MIT License
14
14
  Classifier: Programming Language :: Python :: 3
15
15
  Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
18
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
19
  Requires-Python: >=3.12
19
20
  Requires-Dist: deepagents>=0.6.7
20
21
  Description-Content-Type: text/markdown
21
22
 
22
23
  <p align="center">
23
- <img src="assets/deepagents-docker-banner.png" width="800" />
24
+ <img src="https://github.com/andybbruno/deepagents-docker/blob/master/assets/deepagents-docker-banner.png?raw=true" width="800" />
24
25
  </p>
25
26
  <div align="center">
26
27
  <h3>Docker sandbox backend for <a href="https://github.com/langchain-ai/deepagents">Deep Agents</a>.</h3>
@@ -35,8 +36,7 @@ Description-Content-Type: text/markdown
35
36
 
36
37
  </div>
37
38
 
38
- ## DeepAgents
39
-
39
+ ## deepagents-docker
40
40
  Run [Deep Agents](https://github.com/langchain-ai/deepagents) in an isolated Docker container without compromising your host machine.
41
41
 
42
42
  ## Quickstart
@@ -124,6 +124,43 @@ with DockerSandbox() as backend:
124
124
  print("Done!")
125
125
  ```
126
126
 
127
+ ## Example
128
+
129
+ The [pizza agent](examples/pizza_agent.py) searches the web for a Neapolitan pizza recipe and writes it to a file in the workspace:
130
+
131
+ ```python
132
+ from deepagents import create_deep_agent
133
+ from deepagents_docker import DockerSandbox
134
+
135
+ backend = DockerSandbox(
136
+ workspace_dir="examples/data",
137
+ allow_outbound_traffic=True,
138
+ )
139
+
140
+ agent = create_deep_agent(
141
+ model="openai:gpt-5.5",
142
+ backend=backend,
143
+ system_prompt="You are a pizza chef.",
144
+ )
145
+
146
+ for step in agent.stream(
147
+ {"messages": "Find the best neapolitan pizza recipe and write it to the recipe.md file."},
148
+ stream_mode="updates",
149
+ ):
150
+ for update in step.values():
151
+ if update and (messages := update.get("messages")):
152
+ for message in messages:
153
+ message.pretty_print()
154
+ ```
155
+
156
+ From a clone of this repo (requires an OpenAI API key):
157
+
158
+ ```bash
159
+ uv run python examples/pizza_agent.py
160
+ ```
161
+
162
+ The agent writes `recipe.md` under `examples/data/`.
163
+
127
164
  ## Development
128
165
 
129
166
  ```bash
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="assets/deepagents-docker-banner.png" width="800" />
2
+ <img src="https://github.com/andybbruno/deepagents-docker/blob/master/assets/deepagents-docker-banner.png?raw=true" width="800" />
3
3
  </p>
4
4
  <div align="center">
5
5
  <h3>Docker sandbox backend for <a href="https://github.com/langchain-ai/deepagents">Deep Agents</a>.</h3>
@@ -14,8 +14,7 @@
14
14
 
15
15
  </div>
16
16
 
17
- ## DeepAgents
18
-
17
+ ## deepagents-docker
19
18
  Run [Deep Agents](https://github.com/langchain-ai/deepagents) in an isolated Docker container without compromising your host machine.
20
19
 
21
20
  ## Quickstart
@@ -103,6 +102,43 @@ with DockerSandbox() as backend:
103
102
  print("Done!")
104
103
  ```
105
104
 
105
+ ## Example
106
+
107
+ The [pizza agent](examples/pizza_agent.py) searches the web for a Neapolitan pizza recipe and writes it to a file in the workspace:
108
+
109
+ ```python
110
+ from deepagents import create_deep_agent
111
+ from deepagents_docker import DockerSandbox
112
+
113
+ backend = DockerSandbox(
114
+ workspace_dir="examples/data",
115
+ allow_outbound_traffic=True,
116
+ )
117
+
118
+ agent = create_deep_agent(
119
+ model="openai:gpt-5.5",
120
+ backend=backend,
121
+ system_prompt="You are a pizza chef.",
122
+ )
123
+
124
+ for step in agent.stream(
125
+ {"messages": "Find the best neapolitan pizza recipe and write it to the recipe.md file."},
126
+ stream_mode="updates",
127
+ ):
128
+ for update in step.values():
129
+ if update and (messages := update.get("messages")):
130
+ for message in messages:
131
+ message.pretty_print()
132
+ ```
133
+
134
+ From a clone of this repo (requires an OpenAI API key):
135
+
136
+ ```bash
137
+ uv run python examples/pizza_agent.py
138
+ ```
139
+
140
+ The agent writes `recipe.md` under `examples/data/`.
141
+
106
142
  ## Development
107
143
 
108
144
  ```bash
@@ -0,0 +1,24 @@
1
+ from deepagents import create_deep_agent
2
+
3
+ from deepagents_docker import DockerSandbox
4
+
5
+ backend = DockerSandbox(
6
+ workspace_dir="examples/data",
7
+ allow_outbound_traffic=True,
8
+ )
9
+
10
+ agent = create_deep_agent(
11
+ model="openai:gpt-5.5",
12
+ backend=backend,
13
+ system_prompt="You are a pizza chef.",
14
+ )
15
+
16
+ if __name__ == "__main__":
17
+ for step in agent.stream(
18
+ {"messages": "Find the best neapolitan pizza recipe and write it to the recipe.md file."},
19
+ stream_mode="updates",
20
+ ):
21
+ for update in step.values():
22
+ if update and (messages := update.get("messages")):
23
+ for message in messages:
24
+ message.pretty_print()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "deepagents-docker"
7
- version = "0.0.2"
7
+ version = "0.0.3"
8
8
  description = "Docker-backed sandbox backend for DeepAgents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -19,6 +19,7 @@ classifiers = [
19
19
  "Programming Language :: Python :: 3.12",
20
20
  "Programming Language :: Python :: 3.13",
21
21
  "Topic :: Software Development :: Libraries :: Python Modules",
22
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
22
23
  ]
23
24
  dependencies = [
24
25
  "deepagents>=0.6.7",
@@ -28,6 +29,7 @@ dependencies = [
28
29
  dev = [
29
30
  "build>=1.2.0",
30
31
  "pytest>=9.0.0",
32
+ "ruff>=0.9.0",
31
33
  "twine>=6.0.0",
32
34
  ]
33
35
 
@@ -41,4 +43,18 @@ packages = ["src/deepagents_docker"]
41
43
  [tool.pytest.ini_options]
42
44
  testpaths = ["tests"]
43
45
 
46
+ [tool.ruff]
47
+ target-version = "py312"
48
+ line-length = 100
44
49
 
50
+ [tool.ruff.lint]
51
+ select = [
52
+ # isort
53
+ "I",
54
+
55
+ # Pyflakes
56
+ "F",
57
+
58
+ # Pyupgrade
59
+ "UP",
60
+ ]
@@ -0,0 +1,6 @@
1
+ """Docker-backed sandbox backend for DeepAgents."""
2
+
3
+ from .backend import DockerSandbox
4
+ from .errors import DockerError
5
+
6
+ __all__ = ["DockerError", "DockerSandbox"]
@@ -7,9 +7,7 @@ import subprocess
7
7
  from collections.abc import Sequence
8
8
  from dataclasses import dataclass
9
9
 
10
-
11
- class DockerError(RuntimeError):
12
- """Raised when a Docker CLI invocation fails."""
10
+ from .errors import DockerError
13
11
 
14
12
 
15
13
  @dataclass(frozen=True)
@@ -4,7 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  import atexit
6
6
  import shlex
7
- import subprocess
8
7
  import tempfile
9
8
  import uuid
10
9
  from pathlib import Path
@@ -12,13 +11,13 @@ from pathlib import Path
12
11
  from deepagents.backends.filesystem import FilesystemBackend
13
12
  from deepagents.backends.protocol import ExecuteResponse, SandboxBackendProtocol
14
13
 
15
- from deepagents_docker._docker import (
16
- DockerError,
14
+ from ._docker import (
17
15
  docker_available,
18
16
  format_docker_error,
19
17
  inspect_container_id,
20
18
  run_docker,
21
19
  )
20
+ from .errors import DockerError
22
21
 
23
22
  DEFAULT_EXECUTE_TIMEOUT = 120
24
23
  DEFAULT_IMAGE = "python:3.12-bookworm"
@@ -26,18 +25,7 @@ CONTAINER_WORKDIR = "/workspace"
26
25
 
27
26
 
28
27
  class DockerSandbox(FilesystemBackend, SandboxBackendProtocol):
29
- """Filesystem backend with shell commands executed inside a Docker container.
30
-
31
- File operations (`ls`, `read`, `write`, `edit`, `grep`, `glob`) run against a
32
- dedicated workspace directory on the host via `FilesystemBackend` with
33
- `virtual_mode=True`. The same directory is bind-mounted into the container at
34
- `/workspace`, and the `execute` tool runs commands there with Docker resource
35
- and security limits.
36
-
37
- This is defense in depth, not a perfect isolation boundary. Do not mount
38
- secrets into the workspace, keep Docker patched, and prefer microVMs for
39
- hostile multi-tenant workloads.
40
- """
28
+ """Docker-backed sandbox backend for DeepAgents."""
41
29
 
42
30
  def __init__(
43
31
  self,
@@ -47,8 +35,8 @@ class DockerSandbox(FilesystemBackend, SandboxBackendProtocol):
47
35
  workspace_dir: str | Path | None = None,
48
36
  timeout: int = DEFAULT_EXECUTE_TIMEOUT,
49
37
  max_output_bytes: int = 100_000,
50
- memory: str = "512m",
51
- cpus: float = 1.0,
38
+ memory: str = "256m",
39
+ cpus: float = 0.5,
52
40
  pids_limit: int = 128,
53
41
  auto_remove: bool = True,
54
42
  extra_run_args: list[str] | None = None,
@@ -57,14 +45,14 @@ class DockerSandbox(FilesystemBackend, SandboxBackendProtocol):
57
45
 
58
46
  Args:
59
47
  image: Docker image for command execution (default: official ``python:3.12-bookworm``).
48
+ allow_outbound_traffic: Allow/deny outbound network traffic (default: allow).
60
49
  workspace_dir: Host directory for agent files. A temporary directory is
61
50
  created when omitted.
62
51
  timeout: Default command timeout in seconds.
63
52
  max_output_bytes: Maximum combined stdout/stderr captured per command.
64
- memory: Docker memory limit (for example ``"512m"``).
53
+ memory: Docker memory limit (for example ``"256m"``).
65
54
  cpus: Docker CPU limit.
66
55
  pids_limit: Maximum number of PIDs inside the container.
67
- outbound_traffic: Allow/deny outbound network traffic (default: allow).
68
56
  auto_remove: Remove the container on ``close()``.
69
57
  extra_run_args: Additional ``docker run`` flags appended before the image.
70
58
  """
@@ -146,9 +134,9 @@ class DockerSandbox(FilesystemBackend, SandboxBackendProtocol):
146
134
  "ALL",
147
135
  "--read-only",
148
136
  "--tmpfs",
149
- "/tmp:rw,noexec,nosuid,size=64m",
137
+ "/tmp:rw,noexec,nosuid,size=512m",
150
138
  "--tmpfs",
151
- "/var/tmp:rw,noexec,nosuid,size=64m",
139
+ "/var/tmp:rw,noexec,nosuid,size=512m",
152
140
  "-v",
153
141
  f"{self._workspace}:{CONTAINER_WORKDIR}:rw",
154
142
  "-w",
@@ -198,6 +186,8 @@ class DockerSandbox(FilesystemBackend, SandboxBackendProtocol):
198
186
  wrapped = self._wrap_command(command)
199
187
  docker_args = [
200
188
  "exec",
189
+ "-w",
190
+ CONTAINER_WORKDIR,
201
191
  self._container_name,
202
192
  "sh",
203
193
  "-c",
@@ -205,34 +195,32 @@ class DockerSandbox(FilesystemBackend, SandboxBackendProtocol):
205
195
  ]
206
196
 
207
197
  try:
208
- completed = subprocess.run( # noqa: S602
209
- ["docker", *docker_args],
210
- check=False,
211
- capture_output=True,
212
- text=True,
213
- timeout=effective_timeout,
214
- )
215
- except subprocess.TimeoutExpired:
216
- if timeout is not None:
217
- msg = (
218
- f"Error: Command timed out after {effective_timeout} seconds "
219
- "(custom timeout). The command may be stuck or require more time."
198
+ completed = run_docker(docker_args, timeout=effective_timeout)
199
+ except DockerError as exc:
200
+ detail = str(exc)
201
+ if "timed out" in detail:
202
+ if timeout is not None:
203
+ msg = (
204
+ f"Error: Command timed out after {effective_timeout} seconds "
205
+ "(custom timeout). The command may be stuck or require more time."
206
+ )
207
+ else:
208
+ msg = (
209
+ f"Error: Command timed out after {effective_timeout} seconds. "
210
+ "For long-running commands, re-run using the timeout parameter."
211
+ )
212
+ return ExecuteResponse(output=msg, exit_code=124, truncated=False)
213
+ if "not found on PATH" in detail:
214
+ return ExecuteResponse(
215
+ output=(
216
+ "Error executing command (FileNotFoundError): "
217
+ "docker executable not found on PATH"
218
+ ),
219
+ exit_code=1,
220
+ truncated=False,
220
221
  )
221
- else:
222
- msg = (
223
- f"Error: Command timed out after {effective_timeout} seconds. "
224
- "For long-running commands, re-run using the timeout parameter."
225
- )
226
- return ExecuteResponse(output=msg, exit_code=124, truncated=False)
227
- except FileNotFoundError:
228
- return ExecuteResponse(
229
- output="Error executing command (FileNotFoundError): docker executable not found on PATH",
230
- exit_code=1,
231
- truncated=False,
232
- )
233
- except Exception as exc: # noqa: BLE001
234
222
  return ExecuteResponse(
235
- output=f"Error executing command ({type(exc).__name__}): {exc}",
223
+ output=f"Error executing command (DockerError): {exc}",
236
224
  exit_code=1,
237
225
  truncated=False,
238
226
  )
@@ -0,0 +1,2 @@
1
+ class DockerError(RuntimeError):
2
+ """Raised when a Docker CLI invocation fails."""
@@ -0,0 +1,104 @@
1
+ """Unit tests for low-level Docker CLI helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from unittest.mock import MagicMock, patch
7
+
8
+ import pytest
9
+
10
+ from deepagents_docker._docker import (
11
+ DockerRunResult,
12
+ docker_available,
13
+ format_docker_error,
14
+ inspect_container_id,
15
+ run_docker,
16
+ )
17
+ from deepagents_docker.errors import DockerError
18
+
19
+
20
+ def test_format_docker_error_returns_plain_stderr() -> None:
21
+ result = DockerRunResult(returncode=1, stdout="", stderr="image not found")
22
+ assert format_docker_error(result) == "image not found"
23
+
24
+
25
+ def test_format_docker_error_extracts_json_message() -> None:
26
+ result = DockerRunResult(
27
+ returncode=1,
28
+ stdout="",
29
+ stderr='{"message": "Conflict. The container name is already in use."}',
30
+ )
31
+ assert format_docker_error(result) == "Conflict. The container name is already in use."
32
+
33
+
34
+ def test_format_docker_error_returns_json_without_message_field() -> None:
35
+ payload = '{"errorDetail":{"code":404}}'
36
+ result = DockerRunResult(returncode=1, stdout="", stderr=payload)
37
+ assert format_docker_error(result) == payload
38
+
39
+
40
+ def test_format_docker_error_falls_back_to_exit_code() -> None:
41
+ result = DockerRunResult(returncode=7, stdout="", stderr="")
42
+ assert format_docker_error(result) == "exit code 7"
43
+
44
+
45
+ @patch("deepagents_docker._docker.run_docker")
46
+ def test_docker_available_true_when_info_succeeds(run_docker: MagicMock) -> None:
47
+ run_docker.return_value = DockerRunResult(returncode=0, stdout="25.0.0\n", stderr="")
48
+ assert docker_available() is True
49
+
50
+
51
+ @patch("deepagents_docker._docker.run_docker")
52
+ def test_docker_available_false_when_info_fails(run_docker: MagicMock) -> None:
53
+ run_docker.return_value = DockerRunResult(returncode=1, stdout="", stderr="daemon down")
54
+ assert docker_available() is False
55
+
56
+
57
+ @patch("deepagents_docker._docker.run_docker")
58
+ def test_inspect_container_id_returns_stdout(run_docker: MagicMock) -> None:
59
+ run_docker.return_value = DockerRunResult(returncode=0, stdout="abc123\n", stderr="")
60
+ assert inspect_container_id("my-container") == "abc123"
61
+
62
+
63
+ @patch("deepagents_docker._docker.run_docker")
64
+ def test_inspect_container_id_raises_on_failure(run_docker: MagicMock) -> None:
65
+ run_docker.return_value = DockerRunResult(returncode=1, stdout="", stderr="no such object")
66
+ with pytest.raises(DockerError, match="no such object"):
67
+ inspect_container_id("missing")
68
+
69
+
70
+ @patch("deepagents_docker._docker.run_docker")
71
+ def test_inspect_container_id_fallback_message(run_docker: MagicMock) -> None:
72
+ run_docker.return_value = DockerRunResult(returncode=1, stdout="", stderr="")
73
+ with pytest.raises(DockerError, match="failed to inspect container 'missing'"):
74
+ inspect_container_id("missing")
75
+
76
+
77
+ @patch("deepagents_docker._docker.subprocess.run")
78
+ def test_run_docker_returns_captured_output(subprocess_run: MagicMock) -> None:
79
+ completed = MagicMock()
80
+ completed.returncode = 0
81
+ completed.stdout = "ok\n"
82
+ completed.stderr = ""
83
+ subprocess_run.return_value = completed
84
+
85
+ result = run_docker(["info"])
86
+
87
+ assert result == DockerRunResult(returncode=0, stdout="ok\n", stderr="")
88
+ subprocess_run.assert_called_once()
89
+
90
+
91
+ @patch("deepagents_docker._docker.subprocess.run")
92
+ def test_run_docker_raises_on_timeout(subprocess_run: MagicMock) -> None:
93
+ subprocess_run.side_effect = subprocess.TimeoutExpired(cmd="docker", timeout=5)
94
+
95
+ with pytest.raises(DockerError, match="timed out after 5 seconds"):
96
+ run_docker(["exec", "cid", "true"], timeout=5)
97
+
98
+
99
+ @patch("deepagents_docker._docker.subprocess.run")
100
+ def test_run_docker_raises_when_docker_missing(subprocess_run: MagicMock) -> None:
101
+ subprocess_run.side_effect = FileNotFoundError
102
+
103
+ with pytest.raises(DockerError, match="not found on PATH"):
104
+ run_docker(["info"])
@@ -0,0 +1,543 @@
1
+ """Unit tests for DockerSandbox (Docker CLI mocked)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from unittest.mock import MagicMock, patch
7
+
8
+ import pytest
9
+
10
+ from deepagents_docker import DockerError, DockerSandbox
11
+ from deepagents_docker._docker import DockerRunResult
12
+ from deepagents_docker.backend import DEFAULT_IMAGE
13
+
14
+
15
+ def _docker_run_ok() -> DockerRunResult:
16
+ return DockerRunResult(returncode=0, stdout="", stderr="")
17
+
18
+
19
+ def _make_run_docker_side_effect(**exec_config: object):
20
+ """Build a side_effect for mocked run_docker (handles run/exec/stop/rm)."""
21
+
22
+ def _run_docker(args: list[str], **kwargs: object) -> DockerRunResult:
23
+ if args[0] == "exec":
24
+ if "error" in exec_config:
25
+ raise exec_config["error"]
26
+ return exec_config.get(
27
+ "exec",
28
+ DockerRunResult(returncode=0, stdout="", stderr=""),
29
+ )
30
+ return _docker_run_ok()
31
+
32
+ return _run_docker
33
+
34
+
35
+ @patch("deepagents_docker.backend.run_docker")
36
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
37
+ @patch("deepagents_docker.backend.inspect_container_id", return_value="abc123container")
38
+ def test_default_image_is_python_bookworm(
39
+ _inspect: MagicMock,
40
+ _available: MagicMock,
41
+ run_docker: MagicMock,
42
+ tmp_path: Path,
43
+ ) -> None:
44
+ run_docker.return_value = _docker_run_ok()
45
+
46
+ sandbox = DockerSandbox(workspace_dir=tmp_path)
47
+ try:
48
+ run_args = run_docker.call_args_list[0][0][0]
49
+ assert DEFAULT_IMAGE in run_args
50
+ assert run_args[run_args.index(DEFAULT_IMAGE) + 1 : run_args.index(DEFAULT_IMAGE) + 3] == [
51
+ "sleep",
52
+ "infinity",
53
+ ]
54
+ finally:
55
+ sandbox.close()
56
+
57
+
58
+ @patch("deepagents_docker.backend.run_docker")
59
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
60
+ @patch("deepagents_docker.backend.inspect_container_id", return_value="abc123container")
61
+ def test_start_container_applies_security_flags(
62
+ _inspect: MagicMock,
63
+ _available: MagicMock,
64
+ run_docker: MagicMock,
65
+ tmp_path: Path,
66
+ ) -> None:
67
+ run_docker.return_value = _docker_run_ok()
68
+
69
+ sandbox = DockerSandbox(workspace_dir=tmp_path, image="test-image:local")
70
+ try:
71
+ run_args = run_docker.call_args_list[0][0][0]
72
+ assert "run" in run_args
73
+ assert "--network" in run_args and "bridge" in run_args
74
+ assert "--cap-drop" in run_args and "ALL" in run_args
75
+ assert "--read-only" in run_args
76
+ assert f"{tmp_path.resolve()}:/workspace:rw" in " ".join(run_args)
77
+ assert sandbox.id == "abc123container"
78
+ finally:
79
+ sandbox.close()
80
+
81
+
82
+ @patch("deepagents_docker.backend.run_docker")
83
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
84
+ @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
85
+ def test_start_container_disables_outbound_traffic(
86
+ _inspect: MagicMock,
87
+ _available: MagicMock,
88
+ run_docker: MagicMock,
89
+ tmp_path: Path,
90
+ ) -> None:
91
+ run_docker.return_value = _docker_run_ok()
92
+
93
+ sandbox = DockerSandbox(workspace_dir=tmp_path, allow_outbound_traffic=False)
94
+ try:
95
+ run_args = run_docker.call_args_list[0][0][0]
96
+ network_index = run_args.index("--network")
97
+ assert run_args[network_index + 1] == "none"
98
+ finally:
99
+ sandbox.close()
100
+
101
+
102
+ @patch("deepagents_docker.backend.run_docker")
103
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
104
+ @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
105
+ def test_start_container_applies_resource_limits_and_extra_args(
106
+ _inspect: MagicMock,
107
+ _available: MagicMock,
108
+ run_docker: MagicMock,
109
+ tmp_path: Path,
110
+ ) -> None:
111
+ run_docker.return_value = _docker_run_ok()
112
+
113
+ sandbox = DockerSandbox(
114
+ workspace_dir=tmp_path,
115
+ memory="1g",
116
+ cpus=2.5,
117
+ pids_limit=256,
118
+ extra_run_args=["--env", "FOO=bar"],
119
+ )
120
+ try:
121
+ run_args = run_docker.call_args_list[0][0][0]
122
+ assert run_args[run_args.index("--memory") + 1] == "1g"
123
+ assert run_args[run_args.index("--cpus") + 1] == "2.5"
124
+ assert run_args[run_args.index("--pids-limit") + 1] == "256"
125
+ assert run_args[run_args.index("--env") + 1] == "FOO=bar"
126
+ finally:
127
+ sandbox.close()
128
+
129
+
130
+ @patch("deepagents_docker.backend.inspect_container_id")
131
+ @patch("deepagents_docker.backend.run_docker")
132
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
133
+ def test_raises_when_container_start_fails(
134
+ _available: MagicMock,
135
+ run_docker: MagicMock,
136
+ inspect: MagicMock,
137
+ tmp_path: Path,
138
+ ) -> None:
139
+ run_docker.return_value = DockerRunResult(returncode=1, stdout="", stderr="image not found")
140
+
141
+ with pytest.raises(DockerError, match="failed to start sandbox container: image not found"):
142
+ DockerSandbox(workspace_dir=tmp_path)
143
+
144
+ inspect.assert_not_called()
145
+
146
+
147
+ @patch("deepagents_docker.backend.run_docker")
148
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
149
+ @patch("deepagents_docker.backend.inspect_container_id", side_effect=DockerError("inspect failed"))
150
+ def test_raises_when_container_inspect_fails(
151
+ _inspect: MagicMock,
152
+ _available: MagicMock,
153
+ run_docker: MagicMock,
154
+ tmp_path: Path,
155
+ ) -> None:
156
+ run_docker.return_value = _docker_run_ok()
157
+
158
+ with pytest.raises(DockerError, match="inspect failed"):
159
+ DockerSandbox(workspace_dir=tmp_path)
160
+
161
+
162
+ @pytest.mark.parametrize(
163
+ ("kwargs", "match"),
164
+ [
165
+ ({"timeout": 0}, "timeout must be positive"),
166
+ ({"cpus": 0}, "cpus must be positive"),
167
+ ({"pids_limit": -1}, "pids_limit must be positive"),
168
+ ],
169
+ )
170
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
171
+ def test_constructor_rejects_invalid_limits(
172
+ _available: MagicMock,
173
+ kwargs: dict[str, int],
174
+ match: str,
175
+ ) -> None:
176
+ with pytest.raises(ValueError, match=match):
177
+ DockerSandbox(**kwargs)
178
+
179
+
180
+ @patch("deepagents_docker.backend.docker_available", return_value=False)
181
+ def test_raises_when_docker_unavailable(_available: MagicMock) -> None:
182
+ with pytest.raises(DockerError, match="Docker is not available"):
183
+ DockerSandbox(image="missing:latest")
184
+
185
+
186
+ def test_docker_error_is_public_runtime_error() -> None:
187
+ assert issubclass(DockerError, RuntimeError)
188
+
189
+
190
+ @patch("deepagents_docker.backend.run_docker")
191
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
192
+ @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
193
+ def test_execute_wraps_command_and_returns_output(
194
+ _inspect: MagicMock,
195
+ _available: MagicMock,
196
+ run_docker: MagicMock,
197
+ tmp_path: Path,
198
+ ) -> None:
199
+ run_docker.side_effect = _make_run_docker_side_effect(
200
+ exec=DockerRunResult(returncode=0, stdout="hello\n", stderr=""),
201
+ )
202
+
203
+ sandbox = DockerSandbox(workspace_dir=tmp_path, image="test-image:local")
204
+ try:
205
+ result = sandbox.execute("echo hello")
206
+ assert result.exit_code == 0
207
+ assert "hello" in result.output
208
+
209
+ exec_args = run_docker.call_args_list[1][0][0]
210
+ assert exec_args[:4] == ["exec", "-w", "/workspace", sandbox._container_name]
211
+ shell_cmd = exec_args[-1]
212
+ assert shell_cmd.startswith("cd /workspace && ")
213
+ assert "echo hello" in shell_cmd
214
+ finally:
215
+ sandbox.close()
216
+
217
+
218
+ @patch("deepagents_docker.backend.run_docker")
219
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
220
+ @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
221
+ def test_execute_formats_stderr_and_nonzero_exit(
222
+ _inspect: MagicMock,
223
+ _available: MagicMock,
224
+ run_docker: MagicMock,
225
+ tmp_path: Path,
226
+ ) -> None:
227
+ run_docker.side_effect = _make_run_docker_side_effect(
228
+ exec=DockerRunResult(returncode=2, stdout="", stderr="something broke\n"),
229
+ )
230
+
231
+ sandbox = DockerSandbox(workspace_dir=tmp_path)
232
+ try:
233
+ result = sandbox.execute("false")
234
+ assert result.exit_code == 2
235
+ assert "[stderr] something broke" in result.output
236
+ assert "Exit code: 2" in result.output
237
+ finally:
238
+ sandbox.close()
239
+
240
+
241
+ @patch("deepagents_docker.backend.run_docker")
242
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
243
+ @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
244
+ def test_execute_reports_no_output(
245
+ _inspect: MagicMock,
246
+ _available: MagicMock,
247
+ run_docker: MagicMock,
248
+ tmp_path: Path,
249
+ ) -> None:
250
+ run_docker.side_effect = _make_run_docker_side_effect(
251
+ exec=DockerRunResult(returncode=0, stdout="", stderr=""),
252
+ )
253
+
254
+ sandbox = DockerSandbox(workspace_dir=tmp_path)
255
+ try:
256
+ result = sandbox.execute("true")
257
+ assert result.output == "<no output>"
258
+ finally:
259
+ sandbox.close()
260
+
261
+
262
+ @patch("deepagents_docker.backend.run_docker")
263
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
264
+ @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
265
+ def test_execute_truncates_large_output(
266
+ _inspect: MagicMock,
267
+ _available: MagicMock,
268
+ run_docker: MagicMock,
269
+ tmp_path: Path,
270
+ ) -> None:
271
+ run_docker.side_effect = _make_run_docker_side_effect(
272
+ exec=DockerRunResult(returncode=0, stdout="x" * 200, stderr=""),
273
+ )
274
+
275
+ sandbox = DockerSandbox(workspace_dir=tmp_path, max_output_bytes=50)
276
+ try:
277
+ result = sandbox.execute("printf x")
278
+ assert result.truncated is True
279
+ assert len(result.output) <= 50 + len("\n\n... Output truncated at 50 bytes.")
280
+ assert "Output truncated at 50 bytes" in result.output
281
+ finally:
282
+ sandbox.close()
283
+
284
+
285
+ @patch("deepagents_docker.backend.run_docker")
286
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
287
+ @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
288
+ def test_execute_rejects_empty_command(
289
+ _inspect: MagicMock,
290
+ _available: MagicMock,
291
+ run_docker: MagicMock,
292
+ tmp_path: Path,
293
+ ) -> None:
294
+ run_docker.return_value = _docker_run_ok()
295
+
296
+ sandbox = DockerSandbox(workspace_dir=tmp_path)
297
+ try:
298
+ result = sandbox.execute("")
299
+ assert result.exit_code == 1
300
+ assert "non-empty string" in result.output
301
+ finally:
302
+ sandbox.close()
303
+
304
+
305
+ @patch("deepagents_docker.backend.run_docker")
306
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
307
+ @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
308
+ def test_execute_after_close_returns_error(
309
+ _inspect: MagicMock,
310
+ _available: MagicMock,
311
+ run_docker: MagicMock,
312
+ tmp_path: Path,
313
+ ) -> None:
314
+ run_docker.return_value = _docker_run_ok()
315
+
316
+ sandbox = DockerSandbox(workspace_dir=tmp_path)
317
+ sandbox.close()
318
+ result = sandbox.execute("echo hello")
319
+ assert result.exit_code == 1
320
+ assert "closed" in result.output.lower()
321
+
322
+
323
+ @patch("deepagents_docker.backend.run_docker")
324
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
325
+ @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
326
+ def test_execute_timeout_with_custom_message(
327
+ _inspect: MagicMock,
328
+ _available: MagicMock,
329
+ run_docker: MagicMock,
330
+ tmp_path: Path,
331
+ ) -> None:
332
+ run_docker.side_effect = _make_run_docker_side_effect(
333
+ error=DockerError("docker command timed out after 1 seconds"),
334
+ )
335
+
336
+ sandbox = DockerSandbox(workspace_dir=tmp_path, timeout=1)
337
+ try:
338
+ result = sandbox.execute("sleep 10", timeout=1)
339
+ assert result.exit_code == 124
340
+ assert "custom timeout" in result.output.lower()
341
+ finally:
342
+ sandbox.close()
343
+
344
+
345
+ @patch("deepagents_docker.backend.run_docker")
346
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
347
+ @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
348
+ def test_execute_timeout_with_default_message(
349
+ _inspect: MagicMock,
350
+ _available: MagicMock,
351
+ run_docker: MagicMock,
352
+ tmp_path: Path,
353
+ ) -> None:
354
+ run_docker.side_effect = _make_run_docker_side_effect(
355
+ error=DockerError("docker command timed out after 120 seconds"),
356
+ )
357
+
358
+ sandbox = DockerSandbox(workspace_dir=tmp_path)
359
+ try:
360
+ result = sandbox.execute("sleep 10")
361
+ assert result.exit_code == 124
362
+ assert "timeout parameter" in result.output.lower()
363
+ finally:
364
+ sandbox.close()
365
+
366
+
367
+ @patch("deepagents_docker.backend.run_docker")
368
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
369
+ @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
370
+ def test_execute_when_docker_binary_missing(
371
+ _inspect: MagicMock,
372
+ _available: MagicMock,
373
+ run_docker: MagicMock,
374
+ tmp_path: Path,
375
+ ) -> None:
376
+ run_docker.side_effect = _make_run_docker_side_effect(
377
+ error=DockerError("docker executable not found on PATH"),
378
+ )
379
+
380
+ sandbox = DockerSandbox(workspace_dir=tmp_path)
381
+ try:
382
+ result = sandbox.execute("echo hello")
383
+ assert result.exit_code == 1
384
+ assert "not found on PATH" in result.output
385
+ finally:
386
+ sandbox.close()
387
+
388
+
389
+ @patch("deepagents_docker.backend.run_docker")
390
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
391
+ @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
392
+ def test_execute_rejects_non_positive_timeout(
393
+ _inspect: MagicMock,
394
+ _available: MagicMock,
395
+ run_docker: MagicMock,
396
+ tmp_path: Path,
397
+ ) -> None:
398
+ run_docker.return_value = _docker_run_ok()
399
+
400
+ sandbox = DockerSandbox(workspace_dir=tmp_path)
401
+ try:
402
+ with pytest.raises(ValueError, match="timeout must be positive"):
403
+ sandbox.execute("echo hello", timeout=0)
404
+ finally:
405
+ sandbox.close()
406
+
407
+
408
+ @patch("deepagents_docker.backend.run_docker")
409
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
410
+ @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
411
+ def test_write_and_read_via_virtual_paths(
412
+ _inspect: MagicMock,
413
+ _available: MagicMock,
414
+ run_docker: MagicMock,
415
+ tmp_path: Path,
416
+ ) -> None:
417
+ run_docker.return_value = _docker_run_ok()
418
+
419
+ sandbox = DockerSandbox(workspace_dir=tmp_path, image="test-image:local")
420
+ try:
421
+ write_result = sandbox.write("/notes.txt", "alpha\n")
422
+ assert write_result.error is None
423
+ assert (tmp_path / "notes.txt").read_text() == "alpha\n"
424
+
425
+ read_result = sandbox.read("/notes.txt")
426
+ assert read_result.error is None
427
+ assert read_result.file_data is not None
428
+ assert "alpha" in read_result.file_data["content"]
429
+ finally:
430
+ sandbox.close()
431
+
432
+
433
+ @patch("deepagents_docker.backend.run_docker")
434
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
435
+ @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
436
+ def test_close_stops_and_removes_container(
437
+ _inspect: MagicMock,
438
+ _available: MagicMock,
439
+ run_docker: MagicMock,
440
+ tmp_path: Path,
441
+ ) -> None:
442
+ run_docker.return_value = _docker_run_ok()
443
+
444
+ sandbox = DockerSandbox(workspace_dir=tmp_path)
445
+ container_name = sandbox._container_name
446
+ sandbox.close()
447
+
448
+ stop_call = run_docker.call_args_list[1]
449
+ rm_call = run_docker.call_args_list[2]
450
+ assert stop_call[0][0] == ["stop", "-t", "2", container_name]
451
+ assert rm_call[0][0] == ["rm", "-f", container_name]
452
+
453
+
454
+ @patch("deepagents_docker.backend.run_docker")
455
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
456
+ @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
457
+ def test_close_is_idempotent(
458
+ _inspect: MagicMock,
459
+ _available: MagicMock,
460
+ run_docker: MagicMock,
461
+ tmp_path: Path,
462
+ ) -> None:
463
+ run_docker.return_value = _docker_run_ok()
464
+
465
+ sandbox = DockerSandbox(workspace_dir=tmp_path)
466
+ sandbox.close()
467
+ sandbox.close()
468
+
469
+ assert len(run_docker.call_args_list) == 3
470
+
471
+
472
+ @patch("deepagents_docker.backend.run_docker")
473
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
474
+ @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
475
+ def test_close_skips_remove_when_auto_remove_disabled(
476
+ _inspect: MagicMock,
477
+ _available: MagicMock,
478
+ run_docker: MagicMock,
479
+ tmp_path: Path,
480
+ ) -> None:
481
+ run_docker.return_value = _docker_run_ok()
482
+
483
+ sandbox = DockerSandbox(workspace_dir=tmp_path, auto_remove=False)
484
+ sandbox.close()
485
+
486
+ assert len(run_docker.call_args_list) == 2
487
+ assert run_docker.call_args_list[1][0][0][0] == "stop"
488
+
489
+
490
+ @patch("deepagents_docker.backend.tempfile.mkdtemp")
491
+ @patch("deepagents_docker.backend.run_docker")
492
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
493
+ @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
494
+ def test_close_removes_owned_workspace(
495
+ _inspect: MagicMock,
496
+ _available: MagicMock,
497
+ run_docker: MagicMock,
498
+ mkdtemp: MagicMock,
499
+ tmp_path: Path,
500
+ ) -> None:
501
+ workspace = tmp_path / "owned-workspace"
502
+ workspace.mkdir()
503
+ mkdtemp.return_value = str(workspace)
504
+ run_docker.return_value = _docker_run_ok()
505
+
506
+ sandbox = DockerSandbox()
507
+ sandbox.close()
508
+
509
+ assert not workspace.exists()
510
+
511
+
512
+ @patch("deepagents_docker.backend.run_docker")
513
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
514
+ @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
515
+ def test_close_preserves_user_workspace(
516
+ _inspect: MagicMock,
517
+ _available: MagicMock,
518
+ run_docker: MagicMock,
519
+ tmp_path: Path,
520
+ ) -> None:
521
+ run_docker.return_value = _docker_run_ok()
522
+
523
+ sandbox = DockerSandbox(workspace_dir=tmp_path)
524
+ sandbox.close()
525
+
526
+ assert tmp_path.exists()
527
+
528
+
529
+ @patch("deepagents_docker.backend.run_docker")
530
+ @patch("deepagents_docker.backend.docker_available", return_value=True)
531
+ @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
532
+ def test_context_manager_closes_sandbox(
533
+ _inspect: MagicMock,
534
+ _available: MagicMock,
535
+ run_docker: MagicMock,
536
+ tmp_path: Path,
537
+ ) -> None:
538
+ run_docker.return_value = _docker_run_ok()
539
+
540
+ with DockerSandbox(workspace_dir=tmp_path) as sandbox:
541
+ assert sandbox.id == "cid"
542
+
543
+ assert len(run_docker.call_args_list) == 3
@@ -286,7 +286,7 @@ wheels = [
286
286
 
287
287
  [[package]]
288
288
  name = "deepagents-docker"
289
- version = "0.0.1"
289
+ version = "0.0.2"
290
290
  source = { editable = "." }
291
291
  dependencies = [
292
292
  { name = "deepagents" },
@@ -296,6 +296,7 @@ dependencies = [
296
296
  dev = [
297
297
  { name = "build" },
298
298
  { name = "pytest" },
299
+ { name = "ruff" },
299
300
  { name = "twine" },
300
301
  ]
301
302
 
@@ -306,6 +307,7 @@ requires-dist = [{ name = "deepagents", specifier = ">=0.6.7" }]
306
307
  dev = [
307
308
  { name = "build", specifier = ">=1.2.0" },
308
309
  { name = "pytest", specifier = ">=9.0.0" },
310
+ { name = "ruff", specifier = ">=0.9.0" },
309
311
  { name = "twine", specifier = ">=6.0.0" },
310
312
  ]
311
313
 
@@ -1204,6 +1206,31 @@ wheels = [
1204
1206
  { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
1205
1207
  ]
1206
1208
 
1209
+ [[package]]
1210
+ name = "ruff"
1211
+ version = "0.15.15"
1212
+ source = { registry = "https://pypi.org/simple" }
1213
+ sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" }
1214
+ wheels = [
1215
+ { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" },
1216
+ { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" },
1217
+ { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" },
1218
+ { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" },
1219
+ { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" },
1220
+ { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" },
1221
+ { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" },
1222
+ { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" },
1223
+ { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" },
1224
+ { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" },
1225
+ { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" },
1226
+ { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" },
1227
+ { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" },
1228
+ { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" },
1229
+ { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" },
1230
+ { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" },
1231
+ { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
1232
+ ]
1233
+
1207
1234
  [[package]]
1208
1235
  name = "secretstorage"
1209
1236
  version = "3.5.0"
@@ -1,7 +0,0 @@
1
- """Docker-backed sandbox backend for DeepAgents."""
2
-
3
- from deepagents_docker.backend import (
4
- DockerSandbox,
5
- )
6
-
7
- __all__ = ["DockerSandbox"]
@@ -1,161 +0,0 @@
1
- """Unit tests for DockerSandbox (Docker CLI mocked)."""
2
-
3
- from __future__ import annotations
4
-
5
- import subprocess
6
- from pathlib import Path
7
- from unittest.mock import MagicMock, patch
8
-
9
- import pytest
10
-
11
- from deepagents_docker import DockerSandbox
12
- from deepagents_docker._docker import DockerError, DockerRunResult
13
- from deepagents_docker.backend import DEFAULT_IMAGE
14
-
15
-
16
- def _docker_info_ok() -> DockerRunResult:
17
- return DockerRunResult(returncode=0, stdout="25.0.0\n", stderr="")
18
-
19
-
20
- def _docker_run_ok() -> DockerRunResult:
21
- return DockerRunResult(returncode=0, stdout="", stderr="")
22
-
23
-
24
- def _docker_inspect_ok() -> DockerRunResult:
25
- return DockerRunResult(returncode=0, stdout="abc123container\n", stderr="")
26
-
27
-
28
- @patch("deepagents_docker.backend.run_docker")
29
- @patch("deepagents_docker.backend.docker_available", return_value=True)
30
- @patch("deepagents_docker.backend.inspect_container_id", return_value="abc123container")
31
- def test_default_image_is_python_bookworm(
32
- _inspect: MagicMock,
33
- _available: MagicMock,
34
- run_docker: MagicMock,
35
- tmp_path: Path,
36
- ) -> None:
37
- run_docker.return_value = _docker_run_ok()
38
-
39
- sandbox = DockerSandbox(workspace_dir=tmp_path)
40
- try:
41
- run_args = run_docker.call_args_list[0][0][0]
42
- assert DEFAULT_IMAGE in run_args
43
- assert run_args[
44
- run_args.index(DEFAULT_IMAGE) + 1 : run_args.index(DEFAULT_IMAGE) + 3
45
- ] == [
46
- "sleep",
47
- "infinity",
48
- ]
49
- finally:
50
- sandbox.close()
51
-
52
-
53
- @patch("deepagents_docker.backend.run_docker")
54
- @patch("deepagents_docker.backend.docker_available", return_value=True)
55
- @patch("deepagents_docker.backend.inspect_container_id", return_value="abc123container")
56
- def test_start_container_applies_security_flags(
57
- _inspect: MagicMock,
58
- _available: MagicMock,
59
- run_docker: MagicMock,
60
- tmp_path: Path,
61
- ) -> None:
62
- run_docker.return_value = _docker_run_ok()
63
-
64
- sandbox = DockerSandbox(workspace_dir=tmp_path, image="test-image:local")
65
- try:
66
- run_args = run_docker.call_args_list[0][0][0]
67
- assert "run" in run_args
68
- assert "--network" in run_args and "none" in run_args
69
- assert "--cap-drop" in run_args and "ALL" in run_args
70
- assert "--read-only" in run_args
71
- assert f"{tmp_path.resolve()}:/workspace:rw" in " ".join(run_args)
72
- assert sandbox.id == "abc123container"
73
- finally:
74
- sandbox.close()
75
-
76
-
77
- @patch("deepagents_docker.backend.subprocess.run")
78
- @patch("deepagents_docker.backend.run_docker")
79
- @patch("deepagents_docker.backend.docker_available", return_value=True)
80
- @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
81
- def test_execute_wraps_command_and_returns_output(
82
- _inspect: MagicMock,
83
- _available: MagicMock,
84
- run_docker: MagicMock,
85
- subprocess_run: MagicMock,
86
- tmp_path: Path,
87
- ) -> None:
88
- run_docker.return_value = _docker_run_ok()
89
- completed = MagicMock()
90
- completed.stdout = "hello\n"
91
- completed.stderr = ""
92
- completed.returncode = 0
93
- subprocess_run.return_value = completed
94
-
95
- sandbox = DockerSandbox(workspace_dir=tmp_path, image="test-image:local")
96
- try:
97
- result = sandbox.execute("echo hello")
98
- assert result.exit_code == 0
99
- assert "hello" in result.output
100
-
101
- docker_cmd = subprocess_run.call_args[0][0]
102
- assert docker_cmd[:2] == ["docker", "exec"]
103
- shell_cmd = docker_cmd[-1]
104
- assert shell_cmd.startswith("cd /workspace && ")
105
- assert "echo hello" in shell_cmd
106
- finally:
107
- sandbox.close()
108
-
109
-
110
- @patch("deepagents_docker.backend.run_docker")
111
- @patch("deepagents_docker.backend.docker_available", return_value=True)
112
- @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
113
- def test_write_and_read_via_virtual_paths(
114
- _inspect: MagicMock,
115
- _available: MagicMock,
116
- run_docker: MagicMock,
117
- tmp_path: Path,
118
- ) -> None:
119
- run_docker.return_value = _docker_run_ok()
120
-
121
- sandbox = DockerSandbox(workspace_dir=tmp_path, image="test-image:local")
122
- try:
123
- write_result = sandbox.write("/notes.txt", "alpha\n")
124
- assert write_result.error is None
125
- assert (tmp_path / "notes.txt").read_text() == "alpha\n"
126
-
127
- read_result = sandbox.read("/notes.txt")
128
- assert read_result.error is None
129
- assert read_result.file_data is not None
130
- assert "alpha" in read_result.file_data["content"]
131
- finally:
132
- sandbox.close()
133
-
134
-
135
- @patch("deepagents_docker.backend.docker_available", return_value=False)
136
- def test_raises_when_docker_unavailable(_available: MagicMock) -> None:
137
- with pytest.raises(DockerError, match="Docker is not available"):
138
- DockerSandbox(image="missing:latest")
139
-
140
-
141
- @patch("deepagents_docker.backend.subprocess.run")
142
- @patch("deepagents_docker.backend.run_docker")
143
- @patch("deepagents_docker.backend.docker_available", return_value=True)
144
- @patch("deepagents_docker.backend.inspect_container_id", return_value="cid")
145
- def test_execute_timeout(
146
- _inspect: MagicMock,
147
- _available: MagicMock,
148
- run_docker: MagicMock,
149
- subprocess_run: MagicMock,
150
- tmp_path: Path,
151
- ) -> None:
152
- run_docker.return_value = _docker_run_ok()
153
- subprocess_run.side_effect = subprocess.TimeoutExpired(cmd="docker", timeout=1)
154
-
155
- sandbox = DockerSandbox(workspace_dir=tmp_path, image="test-image:local", timeout=1)
156
- try:
157
- result = sandbox.execute("sleep 10", timeout=1)
158
- assert result.exit_code == 124
159
- assert "timed out" in result.output.lower()
160
- finally:
161
- sandbox.close()