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.
- deepagents_docker-0.0.3/.github/workflows/ci.yml +42 -0
- {deepagents_docker-0.0.2 → deepagents_docker-0.0.3}/PKG-INFO +41 -4
- {deepagents_docker-0.0.2 → deepagents_docker-0.0.3}/README.md +39 -3
- deepagents_docker-0.0.3/examples/pizza_agent.py +24 -0
- {deepagents_docker-0.0.2 → deepagents_docker-0.0.3}/pyproject.toml +17 -1
- deepagents_docker-0.0.3/src/deepagents_docker/__init__.py +6 -0
- {deepagents_docker-0.0.2 → deepagents_docker-0.0.3}/src/deepagents_docker/_docker.py +1 -3
- {deepagents_docker-0.0.2 → deepagents_docker-0.0.3}/src/deepagents_docker/backend.py +35 -47
- deepagents_docker-0.0.3/src/deepagents_docker/errors.py +2 -0
- deepagents_docker-0.0.3/tests/test_docker_helpers.py +104 -0
- deepagents_docker-0.0.3/tests/test_docker_sandbox.py +543 -0
- {deepagents_docker-0.0.2 → deepagents_docker-0.0.3}/uv.lock +28 -1
- deepagents_docker-0.0.2/src/deepagents_docker/__init__.py +0 -7
- deepagents_docker-0.0.2/tests/test_docker_sandbox.py +0 -161
- {deepagents_docker-0.0.2 → deepagents_docker-0.0.3}/.github/workflows/release.yml +0 -0
- {deepagents_docker-0.0.2 → deepagents_docker-0.0.3}/.gitignore +0 -0
- {deepagents_docker-0.0.2 → deepagents_docker-0.0.3}/LICENSE +0 -0
- {deepagents_docker-0.0.2 → deepagents_docker-0.0.3}/assets/deepagents-docker-banner.png +0 -0
|
@@ -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.
|
|
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
|
-
##
|
|
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
|
-
##
|
|
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.
|
|
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
|
+
]
|
|
@@ -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
|
|
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
|
-
"""
|
|
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 = "
|
|
51
|
-
cpus: float =
|
|
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 ``"
|
|
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=
|
|
137
|
+
"/tmp:rw,noexec,nosuid,size=512m",
|
|
150
138
|
"--tmpfs",
|
|
151
|
-
"/var/tmp:rw,noexec,nosuid,size=
|
|
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 =
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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 (
|
|
223
|
+
output=f"Error executing command (DockerError): {exc}",
|
|
236
224
|
exit_code=1,
|
|
237
225
|
truncated=False,
|
|
238
226
|
)
|
|
@@ -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.
|
|
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,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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|