agent-container-sandbox 0.1.1__py3-none-any.whl

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,36 @@
1
+ """Container sandbox backends for LangChain DeepAgents."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from .errors import (
6
+ ContainerNotRunningError,
7
+ ContainerReuseError,
8
+ DockerImageNotFound,
9
+ MissingPythonError,
10
+ MissingTimeoutError,
11
+ SandboxError,
12
+ SpecMismatchError,
13
+ )
14
+ from .managed import ManagedContainerSandbox
15
+ from .provisioning import ContainerProvisioner, ContainerSpec
16
+ from .sandbox import ContainerSandbox
17
+
18
+ try:
19
+ __version__ = version("agent-container-sandbox")
20
+ except PackageNotFoundError: # running from a source checkout without install metadata
21
+ __version__ = "0+unknown"
22
+
23
+ __all__ = [
24
+ "ContainerSandbox",
25
+ "ContainerProvisioner",
26
+ "ContainerSpec",
27
+ "SandboxError",
28
+ "DockerImageNotFound",
29
+ "MissingTimeoutError",
30
+ "MissingPythonError",
31
+ "ContainerReuseError",
32
+ "SpecMismatchError",
33
+ "ContainerNotRunningError",
34
+ "ManagedContainerSandbox",
35
+ "__version__",
36
+ ]
@@ -0,0 +1,132 @@
1
+ """Exceptions raised by agent-container-sandbox.
2
+
3
+ This module only defines exception classes. It must not import anything from
4
+ the rest of the package, so that both ``sandbox`` and ``provisioning`` can
5
+ import from it without risk of circular imports.
6
+
7
+ Hierarchy::
8
+
9
+ SandboxError
10
+ ├── DockerImageNotFound
11
+ ├── MissingTimeoutError
12
+ ├── MissingPythonError
13
+ └── ContainerReuseError
14
+ ├── SpecMismatchError
15
+ └── ContainerNotRunningError
16
+
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import TYPE_CHECKING, Literal
22
+
23
+ if TYPE_CHECKING:
24
+ from docker.models.containers import Container
25
+
26
+ MismatchReason = Literal["not_created_by_library", "no_fingerprint", "different_spec"]
27
+
28
+
29
+ class SandboxError(Exception):
30
+ """Base class for every error this library raises on purpose.
31
+
32
+ Catch this to handle any library-specific failure in one place.
33
+ """
34
+
35
+
36
+ class DockerImageNotFound(SandboxError):
37
+ """Raised when an image is not available locally (and could not be pulled)."""
38
+
39
+ def __init__(self, image: str, detail: str | None = None) -> None:
40
+ self.image = image
41
+ message = f"Image '{image}' is not available locally."
42
+ if detail:
43
+ message += f" {detail}"
44
+ else:
45
+ message += f" Run `docker pull {image}` first, or enable auto_pull."
46
+ super().__init__(message)
47
+
48
+
49
+ class MissingTimeoutError(SandboxError):
50
+ """Raised when a container cannot run commands under ``timeout``.
51
+
52
+ ``ContainerSandbox`` wraps every command in ``timeout``, so the image
53
+ must ship a ``timeout`` binary (GNU coreutils or compatible).
54
+ """
55
+
56
+
57
+ class MissingPythonError(SandboxError):
58
+ """Raised when a container has no usable ``python3``.
59
+
60
+ The file helpers inherited from ``BaseSandbox`` (``read``, ``write``,
61
+ ``edit``, ``ls``, ``glob``) run Python inside the container.
62
+ ``execute`` and the file transfer methods do not need it.
63
+ """
64
+
65
+
66
+ class ContainerReuseError(SandboxError):
67
+ """Raised when ``get_or_create`` finds the name but will not hand the container out.
68
+
69
+ Catch this to handle every reuse failure in one place. The container that
70
+ failed the check is available as ``container``, so recovery is a one-liner:
71
+ ``e.container.start()`` or ``provisioner.delete(e.container)``.
72
+ """
73
+
74
+ def __init__(self, name: str, container: Container, message: str) -> None:
75
+ self.name = name
76
+ self.container = container
77
+ super().__init__(message)
78
+
79
+
80
+ class SpecMismatchError(ContainerReuseError):
81
+ """Raised when the container found is not the one this spec describes.
82
+
83
+ ``reason`` says which check failed:
84
+
85
+ - ``not_created_by_library``: the library's marker label is missing, so
86
+ the name belongs to something else.
87
+ - ``no_fingerprint``: the marker label is present but the spec fingerprint
88
+ is not, which means an earlier library version created it.
89
+ - ``different_spec``: the container's fingerprint differs from the spec's.
90
+ """
91
+
92
+ def __init__(self, name: str, container: Container, reason: MismatchReason) -> None:
93
+ self.reason = reason
94
+ if reason == "not_created_by_library":
95
+ message = (
96
+ f"Container '{name}' exists but was not created by agent-container-sandbox, "
97
+ "so get_or_create will not reuse it. Use another spec.name, or remove it "
98
+ "with provisioner.delete(e.container) if you are sure it is unused."
99
+ )
100
+ elif reason == "no_fingerprint":
101
+ message = (
102
+ f"Container '{name}' was created by agent-container-sandbox but has no spec "
103
+ "fingerprint, probably because an earlier library version created it, so "
104
+ "get_or_create cannot tell whether it matches this spec. Delete it with "
105
+ "provisioner.delete(e.container) to get a new one."
106
+ )
107
+ else:
108
+ message = (
109
+ f"Container '{name}' was created from a different spec (its spec "
110
+ "fingerprint differs). Delete it with provisioner.delete(e.container) to "
111
+ "get a fresh one from this spec, or use another spec.name."
112
+ )
113
+ super().__init__(name, container, message)
114
+
115
+
116
+ class ContainerNotRunningError(ContainerReuseError):
117
+ """Raised when the container matches the spec but is not running.
118
+
119
+ ``status`` is the status string the runtime reported. An expired
120
+ ``max_lifetime_seconds`` is the common cause.
121
+ """
122
+
123
+ def __init__(self, name: str, container: Container, status: str) -> None:
124
+ self.status = status
125
+ super().__init__(
126
+ name,
127
+ container,
128
+ f"Container '{name}' matches the spec but is not running (status: {status}). "
129
+ "Start it with e.container.start(), or delete it with "
130
+ "provisioner.delete(e.container) to get a fresh one. An expired "
131
+ "max_lifetime_seconds is the common cause.",
132
+ )
@@ -0,0 +1,161 @@
1
+ """Context manager for scripts: provision, adapt, and always delete.
2
+
3
+ Convenience layer on top of ``ContainerProvisioner`` and ``ContainerSandbox``
4
+ for the case where the container lives exactly as long as a ``with`` block.
5
+ It adds no capability of its own and delegates everything. For anything more
6
+ complex (reuse, leaving containers behind, direct container access), use the
7
+ provisioner and sandbox directly.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from types import TracebackType
14
+
15
+ import docker
16
+ from docker.models.containers import Container
17
+
18
+ from .provisioning import ContainerProvisioner, ContainerSpec
19
+ from .sandbox import (
20
+ _DEFAULT_COMMAND_TIMEOUT,
21
+ _DEFAULT_MAX_OUTPUT_SIZE,
22
+ ContainerSandbox,
23
+ _validate_limits,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class ManagedContainerSandbox:
30
+ """Provision a container, yield a sandbox for it, and always delete it.
31
+
32
+ Contract: the container lives exactly as long as the ``with`` block.
33
+ There is no option to keep it, no reuse of existing containers, and no
34
+ access to the underlying ``Container`` object. The yielded sandbox exposes
35
+ ``id`` (the short container ID) for ``docker logs`` / ``docker inspect``.
36
+
37
+ Instances are single-use: any second ``__enter__`` raises, even after the
38
+ first block has exited.
39
+
40
+ Args:
41
+ client: Docker client. Owned by the caller; never closed here.
42
+ spec: Description of the container to provision. If ``spec.name`` is
43
+ ``None``, the provisioner generates a unique name.
44
+ working_dir: Optional override passed to ``ContainerSandbox``. If
45
+ ``None``, the sandbox discovers the container's configured
46
+ working directory.
47
+ command_timeout: Default per-command timeout in seconds, passed to
48
+ ``ContainerSandbox``.
49
+ max_output_size: Output truncation limit, passed to
50
+ ``ContainerSandbox``.
51
+ verify_timeout: Run ``ContainerSandbox.verify_timeout()`` on entry so
52
+ an image without a usable ``timeout`` binary fails early with a
53
+ clear error.
54
+ verify_python: Run ``ContainerSandbox.verify_python()`` on entry so an
55
+ image without a usable ``python3`` binary fails early with a clear
56
+ error.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ client: docker.DockerClient,
62
+ spec: ContainerSpec,
63
+ *,
64
+ working_dir: str | None = None,
65
+ command_timeout: int = _DEFAULT_COMMAND_TIMEOUT,
66
+ max_output_size: int = _DEFAULT_MAX_OUTPUT_SIZE,
67
+ verify_timeout: bool = True,
68
+ verify_python: bool = True,
69
+ ) -> None:
70
+ """Store configuration only. No Docker calls happen until ``__enter__``."""
71
+ _validate_limits(command_timeout=command_timeout, max_output_size=max_output_size)
72
+ self._client = client
73
+ self._spec = spec
74
+ self._working_dir = working_dir
75
+ self._command_timeout = command_timeout
76
+ self._max_output_size = max_output_size
77
+ self._verify_timeout = verify_timeout
78
+ self._verify_python = verify_python
79
+ self._provisioner: ContainerProvisioner | None = None
80
+ self._container: Container | None = None # private; never exposed
81
+ self._used = False # set on first __enter__; never reset
82
+
83
+ def __enter__(self) -> ContainerSandbox:
84
+ """Provision the container and return a sandbox over it.
85
+
86
+ Raises:
87
+ RuntimeError: This instance was already entered (single-use).
88
+ MissingTimeoutError: ``verify_timeout`` is enabled and the image
89
+ cannot run commands under ``timeout``.
90
+ MissingPythonError: ``verify_python`` is enabled and the image
91
+ cannot run Python commands.
92
+
93
+ If anything fails after the container has been created, the container
94
+ is deleted before the exception propagates, because ``__exit__`` does
95
+ not run when ``__enter__`` raises.
96
+ """
97
+ if self._used:
98
+ raise RuntimeError(
99
+ "ManagedContainerSandbox is single-use and was already entered. "
100
+ "Create a new instance."
101
+ )
102
+ self._used = True
103
+
104
+ # provision() cleans up after itself on failure, so if it raises there
105
+ # is no container for us to delete and self._container stays None.
106
+ self._provisioner = ContainerProvisioner(self._client)
107
+ self._container = self._provisioner.provision(self._spec)
108
+
109
+ try:
110
+ sandbox = ContainerSandbox(
111
+ self._container,
112
+ working_dir=self._working_dir,
113
+ command_timeout=self._command_timeout,
114
+ max_output_size=self._max_output_size,
115
+ )
116
+ if self._verify_timeout:
117
+ sandbox.verify_timeout()
118
+ if self._verify_python:
119
+ sandbox.verify_python()
120
+ except BaseException:
121
+ # __exit__ will not run, so delete the container here.
122
+ self._cleanup()
123
+ raise
124
+
125
+ return sandbox
126
+
127
+ def __exit__(
128
+ self,
129
+ exc_type: type[BaseException] | None,
130
+ exc_val: BaseException | None,
131
+ exc_tb: TracebackType | None,
132
+ ) -> None:
133
+ """Delete the container, even if the body raised.
134
+
135
+ The original exception always propagates (this returns a falsy value).
136
+ A failure during deletion is logged as a warning and never masks the
137
+ body's exception.
138
+ """
139
+ self._cleanup()
140
+
141
+ def _cleanup(self) -> None:
142
+ """Delete the container if one exists, logging instead of raising.
143
+
144
+ Shared by ``__enter__`` (failed setup) and ``__exit__``.
145
+ """
146
+ # Clear the reference first so a repeated call can never delete twice.
147
+ container, self._container = self._container, None
148
+ if container is None or self._provisioner is None:
149
+ return
150
+
151
+ try:
152
+ self._provisioner.delete(container)
153
+ except Exception:
154
+ # Never mask the body's exception (or a failed __enter__'s cause).
155
+ logger.warning(
156
+ "Cleanup of container %s failed; it will stop on its own after "
157
+ "max_lifetime_seconds. Find leftovers with: "
158
+ "docker ps -a --filter label=created-by=agent-container-sandbox",
159
+ container.short_id,
160
+ exc_info=True,
161
+ )
@@ -0,0 +1,273 @@
1
+ """Container provisioning: create, find, and delete containers.
2
+
3
+ Owns lifecycle only. ContainerSandbox (sandbox.py) owns the adapter logic and
4
+ never creates or deletes anything. The Docker client is injected and owned by
5
+ the caller; this module never closes it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import logging
13
+ import shlex
14
+ import uuid
15
+ from dataclasses import asdict, dataclass, field
16
+ from typing import Any
17
+
18
+ import docker
19
+ from docker.errors import APIError, ImageNotFound
20
+ from docker.models.containers import Container
21
+
22
+ from .errors import ContainerNotRunningError, DockerImageNotFound, SpecMismatchError
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Applied to every container this module creates, regardless of what the
27
+ # caller passes in ContainerSpec.labels. Lets a reaper script find orphans
28
+ # ("docker ps -a --filter label=created-by=agent-container-sandbox") even if
29
+ # the caller supplied no labels of their own.
30
+ _MARKER_LABEL_KEY = "created-by"
31
+ _MARKER_LABEL_VALUE = "agent-container-sandbox"
32
+
33
+ # Hash of the spec a container was created from (see _spec_fingerprint). Set
34
+ # after the marker and the caller's labels, so neither can override it.
35
+ _FINGERPRINT_LABEL_KEY = "agent-container-sandbox.spec-fingerprint"
36
+
37
+ _DEFAULT_WORKING_DIR = "/workspace"
38
+ _DEFAULT_MAX_LIFETIME_SECONDS = 3600 # hard upper bound; not an idle timeout
39
+
40
+
41
+ @dataclass(frozen=True, kw_only=True)
42
+ class ContainerSpec:
43
+ """Immutable description of a container to provision.
44
+
45
+ Fields:
46
+ image: Image name.
47
+ name: Container name. optional; None means the provisioner generates
48
+ a unique name; a name is required for ``ContainerProvisioner.find`` /
49
+ ``get_or_create``.
50
+ volumes: Volume mounts, ``{"/host": {"bind": "/container", "mode": "rw"}}``.
51
+ working_dir: Working directory inside the container. Created if missing.
52
+ max_lifetime_seconds: Hard upper bound on how long the container's
53
+ main process runs. The main process is ``sleep <seconds>``, so the
54
+ container stops on its own even if the caller's process crashes.
55
+ This is a maximum lifetime, not an idle timeout — Docker has no
56
+ idle TTL.
57
+ labels: Additional labels merged with the module's fixed marker
58
+ label; callers cannot omit or override the marker this way.
59
+
60
+ ``get_or_create`` reuses an existing container only if it carries the
61
+ fingerprint of this spec (every field except ``name``) and is running.
62
+ """
63
+
64
+ image: str
65
+ name: str | None = None
66
+ volumes: dict[str, dict[str, str]] = field(default_factory=dict)
67
+ working_dir: str = _DEFAULT_WORKING_DIR
68
+ max_lifetime_seconds: int = _DEFAULT_MAX_LIFETIME_SECONDS
69
+ labels: dict[str, str] = field(default_factory=dict)
70
+ mem_limit: str | None = "2g"
71
+ cpus: float | None = 2.0
72
+ network_mode: str | None = "none"
73
+ pids_limit: int | None = 512
74
+ no_new_privileges: bool = True
75
+ cap_drop_all: bool = True
76
+ cap_add: tuple[str, ...] = ()
77
+
78
+
79
+ def _spec_fingerprint(spec: ContainerSpec) -> str:
80
+ """SHA-256 of the spec's canonical JSON, excluding ``name``.
81
+
82
+ The name is the lookup key, and ``provision`` generates one when it is
83
+ None, so including it would stop a container created from a nameless spec
84
+ from ever matching. ``cap_add`` is sorted because its order is irrelevant;
85
+ dictionary keys are sorted by the JSON encoder.
86
+ """
87
+ data = asdict(spec)
88
+ data.pop("name")
89
+ data["cap_add"] = sorted(data["cap_add"])
90
+ canonical = json.dumps(data, sort_keys=True, separators=(",", ":"))
91
+ return hashlib.sha256(canonical.encode()).hexdigest()
92
+
93
+
94
+ def _hardening_kwargs(spec: ContainerSpec) -> dict[str, Any]:
95
+ """Translate the spec's hardening fields into ``containers.create`` arguments.
96
+
97
+ Disabled settings are omitted, except ``pids_limit``: it is sent as -1
98
+ ("unlimited") so the daemon's own default cannot silently apply.
99
+ """
100
+ kwargs: dict[str, Any] = {}
101
+ if spec.mem_limit is not None:
102
+ kwargs["mem_limit"] = spec.mem_limit
103
+ kwargs["memswap_limit"] = spec.mem_limit # no swap beyond the cap
104
+ if spec.cpus is not None:
105
+ kwargs["nano_cpus"] = int(spec.cpus * 1_000_000_000)
106
+ if spec.network_mode is not None:
107
+ kwargs["network_mode"] = spec.network_mode
108
+ # Always sent. Omitting it would let the daemon apply its own default
109
+ # (2048 on Podman), so None would not actually mean "unlimited".
110
+ kwargs["pids_limit"] = spec.pids_limit if spec.pids_limit is not None else -1
111
+ if spec.no_new_privileges:
112
+ kwargs["security_opt"] = ["no-new-privileges:true"]
113
+ if spec.cap_drop_all:
114
+ kwargs["cap_drop"] = ["ALL"]
115
+ if spec.cap_add:
116
+ kwargs["cap_add"] = list(spec.cap_add)
117
+ return kwargs
118
+
119
+
120
+ class ContainerProvisioner:
121
+ """Creates, finds, and deletes containers via an injected Docker client.
122
+
123
+ Concurrency note: ``get_or_create`` is not safe against two callers
124
+ racing to create a container with the same name at the same time; this
125
+ is a documented limitation, not handled by retrying on a name conflict.
126
+ """
127
+
128
+ def __init__(self, client: docker.DockerClient) -> None:
129
+ self._client = client
130
+
131
+ def _check_image(self, image: str, auto_pull: bool = False) -> None:
132
+ """Ensure ``image`` is available locally, optionally pulling it.
133
+
134
+ Args:
135
+ image: Image name and tag, e.g. ``"python:3.13-slim"``.
136
+ auto_pull: If True, pull the image when it is not found locally.
137
+ If False (the default), a missing image raises immediately.
138
+
139
+ Raises:
140
+ DockerImageNotFound: The image is missing and ``auto_pull`` is False,
141
+ or the pull was attempted and failed (the cause is chained).
142
+ docker.errors.APIError: The Docker daemon failed while checking for
143
+ the image. Propagated unchanged.
144
+ """
145
+ try:
146
+ self._client.images.get(image)
147
+ except ImageNotFound:
148
+ if not auto_pull:
149
+ raise DockerImageNotFound(image) from None
150
+ try:
151
+ self._client.images.pull(image)
152
+ except APIError as e:
153
+ raise DockerImageNotFound(image, detail=f"Pulling it failed: {e}") from e
154
+
155
+ def provision(self, spec: ContainerSpec) -> Container:
156
+ """Create and start a new container from ``spec`` and prepare it for use.
157
+
158
+ If anything fails after the container has been created (starting it or
159
+ creating the working directory), the container is deleted before the
160
+ exception propagates, so a failed call never leaves a container behind.
161
+ If creation itself fails (for example a name conflict), nothing is
162
+ deleted: no container was created by this call.
163
+ """
164
+ self._check_image(spec.image, auto_pull=True)
165
+ name = spec.name or f"agent-container-sandbox-{uuid.uuid4().hex[:12]}"
166
+ # The main process is only a placeholder that keeps the container alive;
167
+ # real work arrives later via exec_run. The entrypoint is set explicitly so
168
+ # an image's own ENTRYPOINT cannot interfere, and init=True runs it behind
169
+ # tini so SIGTERM is forwarded and orphaned child processes are reaped.
170
+ container = self._client.containers.create(
171
+ image=spec.image,
172
+ name=name,
173
+ entrypoint=["sleep", str(spec.max_lifetime_seconds)],
174
+ working_dir=spec.working_dir,
175
+ volumes=spec.volumes,
176
+ labels={
177
+ **spec.labels,
178
+ _MARKER_LABEL_KEY: _MARKER_LABEL_VALUE,
179
+ _FINGERPRINT_LABEL_KEY: _spec_fingerprint(spec),
180
+ },
181
+ stdin_open=False,
182
+ init=True,
183
+ tty=False,
184
+ **_hardening_kwargs(spec),
185
+ )
186
+
187
+ # Everything from here on may leave a created container behind, so start()
188
+ # belongs inside the try. create() stays outside: if it raises, no container
189
+ # was assigned and a same-named one belongs to someone else.
190
+ try:
191
+ container.start()
192
+ exit_code, output_bytes = container.exec_run(
193
+ cmd=["sh", "-c", f"mkdir -p {shlex.quote(spec.working_dir)}"],
194
+ workdir=spec.working_dir,
195
+ )
196
+ if exit_code != 0:
197
+ detail = (output_bytes or b"").decode("utf-8", errors="replace")
198
+ raise RuntimeError(f"mkdir -p {spec.working_dir} failed: {detail}")
199
+ except BaseException:
200
+ try:
201
+ self.delete(container)
202
+ except Exception:
203
+ logger.warning("Cleanup of %s failed", container.short_id, exc_info=True)
204
+ raise
205
+
206
+ return container
207
+
208
+ def find(self, name: str) -> Container | None:
209
+ """Look up an existing container by name.
210
+
211
+ Returns:
212
+ The container if one with this name exists, else ``None``.
213
+ Does not distinguish "never existed" from "existed and was
214
+ removed" — both return ``None``.
215
+ """
216
+ candidates = self._client.containers.list(all=True, filters={"name": name})
217
+ exact = [c for c in candidates if c.name == name]
218
+ return exact[0] if exact else None
219
+
220
+ def _check_reusable(self, container: Container, spec: ContainerSpec) -> None:
221
+ """Raise unless ``container`` is the running container ``spec`` describes.
222
+
223
+ Identity before state: a stopped container with a different spec raises
224
+ ``SpecMismatchError``, because "just start it" would be wrong advice.
225
+ """
226
+ labels = container.labels or {}
227
+ name = spec.name or ""
228
+ if labels.get(_MARKER_LABEL_KEY) != _MARKER_LABEL_VALUE:
229
+ raise SpecMismatchError(name, container, "not_created_by_library")
230
+ stored = labels.get(_FINGERPRINT_LABEL_KEY)
231
+ if stored is None:
232
+ raise SpecMismatchError(name, container, "no_fingerprint")
233
+ if stored != _spec_fingerprint(spec):
234
+ raise SpecMismatchError(name, container, "different_spec")
235
+ if container.status != "running":
236
+ raise ContainerNotRunningError(name, container, container.status)
237
+
238
+ def get_or_create(self, spec: ContainerSpec) -> Container:
239
+ """Return the running container created from ``spec``, or provision one.
240
+
241
+ Looks the container up by ``spec.name``. If one exists, it must carry
242
+ the library's marker label and the fingerprint of this exact spec, and
243
+ it must be running. Nothing is started, stopped or deleted on failure.
244
+
245
+ Raises:
246
+ ValueError: ``spec.name`` is empty.
247
+ SpecMismatchError: The container is not the one this spec describes
248
+ (see its ``reason``).
249
+ ContainerNotRunningError: It is the right container but not running.
250
+ """
251
+ if not spec.name:
252
+ raise ValueError("get_or_create requires spec.name; it is the lookup key.")
253
+ container = self.find(spec.name)
254
+ if container:
255
+ self._check_reusable(container, spec)
256
+ return container
257
+ return self.provision(spec)
258
+
259
+ def delete(self, container: Container) -> None:
260
+ """Stop and remove a container.
261
+
262
+ Tolerates the container already being gone (``docker.errors.NotFound``,
263
+ from either ``stop()`` or ``remove()``) without raising, since that's a
264
+ successful end state, not a failure.
265
+ """
266
+ try:
267
+ container.stop(timeout=5)
268
+ container.remove(force=True)
269
+ except docker.errors.NotFound:
270
+ logger.debug("Container %s already gone", container.short_id)
271
+ return
272
+
273
+ logger.info("Container %s removed", container.short_id)
File without changes
@@ -0,0 +1,310 @@
1
+ from __future__ import annotations
2
+
3
+ import codecs
4
+ import io
5
+ import logging
6
+ import shlex
7
+ import tarfile
8
+ import threading
9
+ import time
10
+ from typing import Any
11
+
12
+ import docker
13
+ from deepagents.backends.protocol import (
14
+ ExecuteResponse,
15
+ FileDownloadResponse,
16
+ FileUploadResponse,
17
+ )
18
+ from deepagents.backends.sandbox import BaseSandbox
19
+ from docker.models.containers import Container
20
+
21
+ from .errors import MissingPythonError, MissingTimeoutError
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ _DEFAULT_COMMAND_TIMEOUT = 150 # seconds
26
+ _DEFAULT_MAX_OUTPUT_SIZE = 64 * 1024 # 64 KiB
27
+ _KILL_DELAY = 2 # seconds to wait after sending SIGTERM before sending SIGKILL
28
+ _GO_MODE_DIR = 1 << 31 # os.ModeDir: Docker's path stat uses Go's FileMode, not POSIX bits
29
+ _TIMEOUT_GRACE_SECONDS = 5 # headroom for the container-side kill to land before we give up waiting
30
+ _TIMEOUT_PROBE = "timeout -k 1 1 true" # must match the form execute() uses
31
+ _PYTHON_PROBE = "python3 -c pass"
32
+ _POSIX_TIMEOUT_EXIT_CODE = 124
33
+ _SANDBOX_ERROR_PREFIX = "Sandbox error:"
34
+
35
+
36
+ def _decode_and_truncate(raw: bytes, limit: int) -> tuple[str, bool]:
37
+ """Decode command output, cutting it at ``limit`` raw bytes.
38
+
39
+ No note is appended: deepagents' helpers parse the output and only forgive a
40
+ torn final line when ``truncated`` is set, and its execute tool adds its own
41
+ notice from that flag.
42
+ """
43
+ if len(raw) <= limit:
44
+ return raw.decode("utf-8", errors="replace"), False
45
+ decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
46
+ return decoder.decode(raw[:limit], final=False), True # holds back a split trailing character
47
+
48
+
49
+ def _resolve_path(path: str, working_dir: str) -> str:
50
+ return path if path.startswith("/") else f"{working_dir}/{path}"
51
+
52
+
53
+ def _build_single_file_tar(file_path: str, content: bytes) -> io.BytesIO:
54
+ """Build a single-file tar archive ready for put_archive."""
55
+ tar_stream = io.BytesIO()
56
+ with tarfile.open(fileobj=tar_stream, mode="w") as tar:
57
+ info = tarfile.TarInfo(name=file_path.lstrip("/"))
58
+ info.size = len(content)
59
+ info.mtime = int(time.time()) # TarInfo defaults to 0, which dates files 1970
60
+ tar.addfile(info, io.BytesIO(content))
61
+ tar_stream.seek(0)
62
+ return tar_stream
63
+
64
+
65
+ def _is_directory(path_stat: dict | None) -> bool:
66
+ return path_stat is not None and bool(path_stat.get("mode", 0) & _GO_MODE_DIR)
67
+
68
+
69
+ def _extract_single_file(tar_data: io.BytesIO) -> tuple[bytes | None, str | None]:
70
+ """Given a downloaded tar archive, return (content, error)."""
71
+ with tarfile.open(fileobj=tar_data, mode="r") as tar:
72
+ members = tar.getmembers()
73
+ f = tar.extractfile(members[0]) if members else None
74
+ if f is None:
75
+ return None, "file_not_found"
76
+ return f.read(), None
77
+
78
+
79
+ def _validate_limits(*, command_timeout: int, max_output_size: int) -> None:
80
+ """Reject settings that have no sensible meaning. Raises ValueError."""
81
+ if command_timeout <= 0:
82
+ raise ValueError(f"command_timeout must be positive, got {command_timeout}.")
83
+ if max_output_size <= 0:
84
+ raise ValueError(f"max_output_size must be positive, got {max_output_size}.")
85
+
86
+
87
+ class ContainerSandbox(BaseSandbox):
88
+ """Deep Agents sandbox backed by an existing, running Docker container.
89
+
90
+ A thin adapter: it runs commands and transfers files inside a container
91
+ you already have. It never creates, stops, or removes containers and never
92
+ closes the Docker client. Use ``ContainerProvisioner`` for lifecycle.
93
+
94
+ The container image must provide ``sh`` and a ``timeout`` binary.
95
+ Commands exceeding their timeout are killed inside the container and
96
+ return exit code 124.
97
+
98
+ Args:
99
+ container: A running container. The caller owns its lifecycle.
100
+ working_dir: Directory used for commands and relative paths. If None,
101
+ the container's configured working directory is used (``/`` if the
102
+ image sets none). If given, it must already exist.
103
+ command_timeout: Default per-command timeout in seconds.
104
+ max_output_size: Output beyond this size is truncated.
105
+ """
106
+
107
+ def __init__(
108
+ self,
109
+ container: Container,
110
+ *,
111
+ working_dir: str | None = None,
112
+ command_timeout: int = _DEFAULT_COMMAND_TIMEOUT,
113
+ max_output_size: int = _DEFAULT_MAX_OUTPUT_SIZE,
114
+ ) -> None:
115
+ _validate_limits(command_timeout=command_timeout, max_output_size=max_output_size)
116
+ self._container = container
117
+ self._working_dir = working_dir or self._discover_working_dir()
118
+ self._default_timeout = command_timeout
119
+ self._max_output_bytes = max_output_size
120
+
121
+ @property
122
+ def id(self) -> str:
123
+ return self._container.short_id
124
+
125
+ def execute(
126
+ self,
127
+ command: str,
128
+ *,
129
+ timeout: int | None = None,
130
+ ) -> ExecuteResponse:
131
+ if not command or not isinstance(command, str):
132
+ return ExecuteResponse(
133
+ output=f"{_SANDBOX_ERROR_PREFIX} Command must be a non-empty string.",
134
+ exit_code=1,
135
+ truncated=False,
136
+ )
137
+ if timeout is not None and timeout <= 0:
138
+ return ExecuteResponse(
139
+ output=(
140
+ f"{_SANDBOX_ERROR_PREFIX} timeout must be a positive "
141
+ f"number of seconds (got {timeout})."
142
+ ),
143
+ exit_code=1,
144
+ truncated=False,
145
+ )
146
+ effective_timeout = timeout if timeout is not None else self._default_timeout
147
+
148
+ result_holder: dict[str, Any] = {}
149
+ execution_done = threading.Event()
150
+
151
+ def run_command() -> None:
152
+ try:
153
+ exit_code, output_bytes = self._container.exec_run(
154
+ cmd=[
155
+ "timeout",
156
+ "-k",
157
+ str(_KILL_DELAY),
158
+ str(effective_timeout),
159
+ "sh",
160
+ "-c",
161
+ command,
162
+ ],
163
+ stdout=True,
164
+ stderr=True,
165
+ demux=False,
166
+ workdir=self._working_dir,
167
+ )
168
+ output, truncated = _decode_and_truncate(
169
+ output_bytes or b"", self._max_output_bytes
170
+ )
171
+ if exit_code == _POSIX_TIMEOUT_EXIT_CODE:
172
+ output += (
173
+ f"\n\n[Command timed out after {effective_timeout} seconds and was killed.]"
174
+ )
175
+ result_holder.update(exit_code=exit_code, output=output, truncated=truncated)
176
+ except Exception as e:
177
+ result_holder.update(
178
+ exit_code=1,
179
+ output=(
180
+ f"{_SANDBOX_ERROR_PREFIX} Error executing command: {type(e).__name__}: {e}"
181
+ ),
182
+ truncated=False,
183
+ )
184
+ finally:
185
+ execution_done.set()
186
+
187
+ thread = threading.Thread(target=run_command, daemon=True)
188
+ thread.start()
189
+
190
+ if not execution_done.wait(
191
+ timeout=effective_timeout + _KILL_DELAY + _TIMEOUT_GRACE_SECONDS
192
+ ):
193
+ # The container's own `timeout` should have killed the command by
194
+ # effective_timeout. Reaching here means even that didn't work --
195
+ # Docker itself is unresponsive, not just the command running long.
196
+ return ExecuteResponse(
197
+ output=(
198
+ f"{_SANDBOX_ERROR_PREFIX} No response from Docker within "
199
+ f"{effective_timeout + _KILL_DELAY + _TIMEOUT_GRACE_SECONDS} seconds; "
200
+ f"the command's state is unknown. The sandbox may be unhealthy."
201
+ ),
202
+ exit_code=124,
203
+ truncated=False,
204
+ )
205
+
206
+ return ExecuteResponse(
207
+ output=result_holder["output"],
208
+ exit_code=result_holder["exit_code"],
209
+ truncated=result_holder["truncated"],
210
+ )
211
+
212
+ def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
213
+ """Upload files to the container via tar archive."""
214
+ responses: list[FileUploadResponse] = []
215
+
216
+ for file_path, content in files:
217
+ try:
218
+ file_path = _resolve_path(file_path, self._working_dir)
219
+ dir_path = file_path.rsplit("/", 1)[0] or "/"
220
+
221
+ tar_stream = _build_single_file_tar(file_path, content)
222
+
223
+ mkdir_result = self.execute(f"mkdir -p {shlex.quote(dir_path)}")
224
+ if mkdir_result.exit_code != 0:
225
+ raise RuntimeError(f"mkdir -p {dir_path} failed: {mkdir_result.output}")
226
+
227
+ # put_archive would silently replace a directory with a file (observed
228
+ # on Podman), deleting its content. Not atomic with the write below.
229
+ check = self.execute(f"test -d {shlex.quote(file_path)}")
230
+ if check.exit_code == 0:
231
+ raise IsADirectoryError(
232
+ f"{file_path} is an existing directory; refusing to replace it"
233
+ )
234
+
235
+ self._container.put_archive("/", tar_stream)
236
+
237
+ responses.append(FileUploadResponse(path=file_path, error=None))
238
+ logger.debug("Uploaded file: %s (%d bytes)", file_path, len(content))
239
+
240
+ except Exception as e:
241
+ error_msg = f"Failed to upload {file_path}: {type(e).__name__}: {e}"
242
+ logger.error("%s", error_msg)
243
+ responses.append(FileUploadResponse(path=file_path, error=error_msg))
244
+
245
+ return responses
246
+
247
+ def _download_one(self, file_path: str) -> FileDownloadResponse:
248
+ path = _resolve_path(file_path, self._working_dir)
249
+ try:
250
+ tar_stream, path_stat = self._container.get_archive(path)
251
+ if _is_directory(path_stat):
252
+ # Return before iterating: a large directory is never loaded into memory.
253
+ return FileDownloadResponse(path=file_path, content=None, error="is_directory")
254
+ tar_data = io.BytesIO()
255
+ for chunk in tar_stream:
256
+ tar_data.write(chunk)
257
+ tar_data.seek(0)
258
+ content, error = _extract_single_file(tar_data)
259
+ except docker.errors.NotFound:
260
+ content, error = None, self._not_found_error(path)
261
+ except Exception as e:
262
+ error_msg = f"Failed to download {path}: {type(e).__name__}: {e}"
263
+ logger.error("%s", error_msg)
264
+ content, error = None, error_msg
265
+ return FileDownloadResponse(path=file_path, content=content, error=error)
266
+
267
+ def _not_found_error(self, path: str) -> str:
268
+ """The daemon raises NotFound for a missing file and for a missing container."""
269
+ try:
270
+ self._container.reload()
271
+ except docker.errors.NotFound:
272
+ return f"Failed to download {path}: container no longer exists"
273
+ except Exception:
274
+ logger.debug("reload() failed while classifying NotFound", exc_info=True)
275
+ return "file_not_found"
276
+
277
+ def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
278
+ return [self._download_one(p) for p in paths]
279
+
280
+ def verify_timeout(self) -> None:
281
+ """Raise if the container cannot run commands under ``timeout``."""
282
+ exit_code, output_bytes = self._container.exec_run(
283
+ cmd=["sh", "-c", _TIMEOUT_PROBE],
284
+ workdir="/", # not working_dir: the probe shouldn't depend on it existing
285
+ )
286
+ if exit_code != 0:
287
+ detail = (output_bytes or b"").decode("utf-8", errors="replace").strip()
288
+ raise MissingTimeoutError(
289
+ f"Container image cannot run `{_TIMEOUT_PROBE}` (exit {exit_code}): "
290
+ f"{detail}. ContainerSandbox requires a `timeout` binary "
291
+ f"(GNU coreutils or compatible)."
292
+ )
293
+
294
+ def verify_python(self) -> None:
295
+ """Raise if the container has no usable ``python3``."""
296
+ exit_code, output_bytes = self._container.exec_run(
297
+ cmd=["sh", "-c", _PYTHON_PROBE], workdir="/"
298
+ )
299
+ if exit_code != 0:
300
+ detail = (output_bytes or b"").decode("utf-8", errors="replace").strip()
301
+ raise MissingPythonError(
302
+ f"Container cannot run `{_PYTHON_PROBE}` (exit {exit_code}): {detail}. "
303
+ "The file helpers inherited from BaseSandbox (read, write, edit, ls, glob) "
304
+ "need `python3`. If you only use execute and file transfer, you can skip this "
305
+ "check (e.g., verify_python=False in ManagedContainerSandbox)."
306
+ )
307
+
308
+ def _discover_working_dir(self) -> str:
309
+ self._container.reload() # refresh attrs from the daemon
310
+ return self._container.attrs.get("Config", {}).get("WorkingDir") or "/"
@@ -0,0 +1,334 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-container-sandbox
3
+ Version: 0.1.1
4
+ Summary: Docker container sandbox for LangChain Deep Agents, with the container lifecycle kept separate from the sandbox.
5
+ Keywords: langchain,deepagents,docker,sandbox,agents
6
+ Author: Konovor
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Typing :: Typed
16
+ Requires-Dist: deepagents>=0.7.10,<0.8
17
+ Requires-Dist: docker>=7.2.0
18
+ Requires-Python: >=3.11
19
+ Project-URL: Homepage, https://github.com/Konovor/agent-container-sandbox
20
+ Project-URL: Issues, https://github.com/Konovor/agent-container-sandbox/issues
21
+ Description-Content-Type: text/markdown
22
+
23
+ # agent-container-sandbox
24
+
25
+ Docker container sandbox for [LangChain Deep Agents](https://github.com/langchain-ai/deepagents): run commands and move files inside a container, with the container lifecycle kept separate from the sandbox itself.
26
+
27
+ - **`ContainerSandbox`** is a thin adapter around a container you already have. It runs commands and transfers files. It never creates, stops, or removes containers.
28
+ - **`ContainerProvisioner`** creates, finds, and deletes containers. It never runs your commands.
29
+ - **`ContainerSpec`** is an immutable description of the container you want.
30
+ - **`ManagedContainerSandbox`** is a context manager for scripts. It provisions a container, hands you a `ContainerSandbox`, and always deletes the container when the `with` block ends.
31
+
32
+ You own the Docker client. The library never closes it. You also own the container lifecycle, unless you hand it to `ManagedContainerSandbox`.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install agent-container-sandbox
38
+ ```
39
+
40
+ or with uv: `uv add agent-container-sandbox`. To install from git instead: `uv add git+https://github.com/Konovor/agent-container-sandbox@v0.1.1`.
41
+
42
+ **Prerequisites**
43
+
44
+ - Docker installed and running. Podman works through its Docker-compatible socket: point `DOCKER_HOST` at it, and `docker.from_env()` picks it up.
45
+ - An image that provides `sh`, `sleep`, and `timeout` (GNU coreutils). Very minimal images (for example `scratch`-based ones) will not work.
46
+ - `python3` in the image, if you use the file helpers inherited from `BaseSandbox` (`read`, `write`, `edit`, `ls`, `glob`). They run Python inside the container. Without it, `read`, `write`, `edit` and `glob` return an error, but `ls` returns an empty list instead of an error. `grep`, `execute`, `upload_files` and `download_files` do not need it (observed with deepagents 0.7.19 and 0.7.32). `ManagedContainerSandbox` checks for `python3` by default and fails early if it is missing.
47
+
48
+ The recommended image meets all of this:
49
+
50
+ ```bash
51
+ docker pull ghcr.io/astral-sh/uv:0.10.0-python3.13-trixie-slim
52
+ ```
53
+
54
+ Pulling beforehand is optional: the provisioner pulls a missing image automatically (this needs registry access and can make the first `provision()` slow). If the pull fails, it raises `DockerImageNotFound`.
55
+
56
+ ## Quick start
57
+
58
+ ```python
59
+ import docker
60
+
61
+ from agent_container_sandbox import ContainerSpec, ManagedContainerSandbox
62
+
63
+ IMAGE = "ghcr.io/astral-sh/uv:0.10.0-python3.13-trixie-slim"
64
+
65
+ client = docker.from_env()
66
+ try:
67
+ with ManagedContainerSandbox(client, ContainerSpec(image=IMAGE)) as sandbox:
68
+ result = sandbox.execute("echo 'Hello from your sandbox!'")
69
+ print(result.output) # Hello from your sandbox!
70
+ print(result.exit_code) # 0
71
+ finally:
72
+ client.close()
73
+ ```
74
+
75
+ The container is deleted when the block ends, even if the body raises. You still close the client yourself.
76
+
77
+ Pass any `docker.DockerClient`; see the docker-py documentation for remote and TLS setups.
78
+
79
+ ### Manual lifecycle
80
+
81
+ Use the provisioner and sandbox directly when you need reuse, want to leave the container running, or need the `Container` object:
82
+
83
+ ```python
84
+ import docker
85
+
86
+ from agent_container_sandbox import ContainerProvisioner, ContainerSandbox, ContainerSpec
87
+
88
+ IMAGE = "ghcr.io/astral-sh/uv:0.10.0-python3.13-trixie-slim"
89
+
90
+ client = docker.from_env()
91
+ provisioner = ContainerProvisioner(client)
92
+ container = provisioner.provision(ContainerSpec(image=IMAGE, name="my-sandbox"))
93
+
94
+ try:
95
+ sandbox = ContainerSandbox(container)
96
+ print(sandbox.execute("echo 'Hello from your sandbox!'").output)
97
+ finally:
98
+ provisioner.delete(container)
99
+ client.close()
100
+ ```
101
+
102
+ Always delete the container in a `finally` block. If your process crashes, `max_lifetime_seconds` (see below) is the safety net.
103
+
104
+ ## Using it with an agent
105
+
106
+ Any LangChain chat model works. This example uses Google Gemini, which requires `langchain-google-genai` and a `GOOGLE_API_KEY`.
107
+
108
+ ```python
109
+ from deepagents import create_deep_agent
110
+ from langchain.chat_models import init_chat_model
111
+
112
+ # `sandbox` is the ContainerSandbox from the snippets above
113
+ agent = create_deep_agent(
114
+ model=init_chat_model("google_genai:gemini-3.5-flash-lite"),
115
+ system_prompt="You are a coding assistant with sandbox access.",
116
+ backend=sandbox,
117
+ )
118
+ result = agent.invoke(
119
+ {"messages": [{"role": "user", "content": "Write and run a script for Armstrong numbers"}]}
120
+ )
121
+ ```
122
+
123
+ The container has no network by default, so tasks that need `pip install`, `git clone` or web requests require `ContainerSpec(image=IMAGE, network_mode="bridge")`.
124
+
125
+ For plain LangChain agents, pass the sandbox through `FilesystemMiddleware(backend=sandbox)` from `deepagents.middleware.filesystem`.
126
+
127
+ With `ManagedContainerSandbox`, keep the agent call inside the `with` block. The container is gone afterwards.
128
+
129
+ ### Direct usage
130
+
131
+ `ContainerSandbox` inherits the file helpers from `BaseSandbox` (`read`, `write`, `ls`, `edit`, and so on) and adds binary transfer:
132
+
133
+ ```python
134
+ sandbox.write("/workspace/test.txt", "Hello, World!")
135
+
136
+ sandbox.upload_files([("data.bin", b"\x00\x01\x02")]) # relative paths resolve to working_dir
137
+ files = sandbox.download_files(["data.bin"])
138
+ print(files[0].content) # b'\x00\x01\x02'
139
+
140
+ result = sandbox.execute("sleep 10", timeout=2)
141
+ print(result.exit_code) # 124
142
+ ```
143
+
144
+ `upload_files` and `download_files` are intended for the developer, not the model: you control what enters the sandbox and what you extract.
145
+
146
+ ## Container lifecycle
147
+
148
+ ### `ContainerSpec`
149
+
150
+ | Field | Type | Default | Description |
151
+ |-------|------|---------|-------------|
152
+ | `image` | `str` | required | Image name. Pulled automatically if missing locally. |
153
+ | `name` | `str \| None` | `None` | Container name. If omitted, a unique name is generated. Required for `find` and `get_or_create`, where it is the lookup key. |
154
+ | `volumes` | `dict` | `{}` | Mounts, e.g. `{"/host": {"bind": "/container", "mode": "rw"}}`. |
155
+ | `working_dir` | `str` | `/workspace` | Working directory. Created if missing. |
156
+ | `max_lifetime_seconds` | `int` | `3600` | Hard upper bound on the container's life (see below). |
157
+ | `labels` | `dict` | `{}` | Extra labels. The marker label below and a spec fingerprint label are always added and cannot be overridden. |
158
+ | `mem_limit` | `str \| None` | `"2g"` | Memory cap. Swap is capped at the same value. `None` = unlimited. |
159
+ | `cpus` | `float \| None` | `2.0` | CPU cap in cores. `None` = unlimited. |
160
+ | `network_mode` | `str \| None` | `"none"` | Container network mode. `"none"` disables networking. Use `"bridge"` for normal network access. `"host"` shares the host's network stack and removes the isolation. `None` sends nothing, so the daemon's default applies (usually full network access). |
161
+ | `pids_limit` | `int \| None` | `512` | Maximum processes and threads. `None` = unlimited. |
162
+ | `no_new_privileges` | `bool` | `True` | Block privilege gain through setuid binaries. |
163
+ | `cap_drop_all` | `bool` | `True` | Drop all Linux capabilities. |
164
+ | `cap_add` | `tuple[str, ...]` | `()` | Capabilities to add back after dropping, e.g. `("CHOWN", "DAC_OVERRIDE")`. |
165
+
166
+ All fields are keyword-only: write `ContainerSpec(image=IMAGE)`, not `ContainerSpec(IMAGE)`.
167
+
168
+ ### `ContainerProvisioner`
169
+
170
+ | Method | Description |
171
+ |--------|-------------|
172
+ | `provision(spec)` | Create and start a new container. Cleans up after itself if setup fails. |
173
+ | `find(name)` | Return the container with exactly this name, or `None`. |
174
+ | `get_or_create(spec)` | Return the running container created from this spec, looked up by `spec.name`, or provision one. Raises `SpecMismatchError` or `ContainerNotRunningError` if the existing container does not match or is not running. |
175
+ | `delete(container)` | Stop and remove a container. Tolerates it already being gone. |
176
+
177
+ **Reusing containers.** `get_or_create` lets several `ContainerSandbox` objects share one container, or attach to one that another process started from the same spec. It looks the container up by `spec.name` and hands it out only if it carries the library's marker label and the fingerprint of this exact spec (every field except `name`), and is running. Otherwise it raises and leaves the container as it is: nothing is started, stopped, or deleted. The error carries the container as `e.container`, so you can recover without a second lookup:
178
+
179
+ ```python
180
+ from agent_container_sandbox import ContainerNotRunningError, SpecMismatchError
181
+
182
+ try:
183
+ container = provisioner.get_or_create(spec)
184
+ except ContainerNotRunningError as e:
185
+ e.container.start() # or provisioner.delete(e.container), then get_or_create(spec) again
186
+ container = e.container
187
+ except SpecMismatchError as e:
188
+ # e.reason: "not_created_by_library", "no_fingerprint" or "different_spec"
189
+ provisioner.delete(e.container) # only if you are sure it is unused
190
+ container = provisioner.get_or_create(spec)
191
+ ```
192
+
193
+ A container that is not running is usually one whose `max_lifetime_seconds` has expired. A `SpecMismatchError` means the container is not the one your spec describes, so check what it is before you delete it. Catch `ContainerReuseError` to handle both failures in one place.
194
+
195
+ `find()` does not verify the container, and `get_or_create()` does. `ContainerSandbox` accepts any running container as it is and does not check its configuration, so the hardening only applies to containers created from a `ContainerSpec`. A sandbox over a container that did not come from your spec uses that container's own working directory, which may differ from your spec. `ManagedContainerSandbox` does not reuse containers.
196
+
197
+ ### `ManagedContainerSandbox`
198
+
199
+ A convenience layer over `ContainerProvisioner` and `ContainerSandbox`. It adds no capability of its own.
200
+
201
+ ```python
202
+ with ManagedContainerSandbox(client, spec) as sandbox:
203
+ ... # sandbox is a ContainerSandbox
204
+ ```
205
+
206
+ | Parameter | Type | Default | Description |
207
+ |-----------|------|---------|-------------|
208
+ | `client` | `DockerClient` | required | Your Docker client. Never closed by this class. |
209
+ | `spec` | `ContainerSpec` | required | The container to provision. If `spec.name` is `None`, a unique name is generated. |
210
+ | `working_dir` | `str \| None` | container's configured working directory | Passed to `ContainerSandbox`. |
211
+ | `command_timeout` | `int` | `150` | Passed to `ContainerSandbox`. Must be positive. |
212
+ | `max_output_size` | `int` | `65536` | Passed to `ContainerSandbox`. Must be positive. |
213
+ | `verify_timeout` | `bool` | `True` | Run `verify_timeout()` on entry, so an image without a usable `timeout` binary fails early. |
214
+ | `verify_python` | `bool` | `True` | Run `verify_python()` on entry, so an image without `python3` fails early. Set it to `False` if you only use `execute` and file transfer. |
215
+
216
+ - **Cleanup is guaranteed.** The container is deleted on exit, including when the body raises. If setup fails after the container was created (for example `MissingTimeoutError` or `MissingPythonError`), it is deleted before the exception propagates.
217
+ - **Errors are never masked.** If deletion itself fails, a warning is logged and your original exception still propagates. The container then stops on its own after `max_lifetime_seconds`.
218
+ - **Single-use.** Entering the same instance twice raises `RuntimeError`. Create a new instance.
219
+ - **Fixed names can collide.** With an explicit `spec.name`, entering while a container of that name still exists fails with a docker-py error. Omit the name unless you need it.
220
+
221
+ ### How the container is kept alive
222
+
223
+ The provisioner starts the container with a placeholder process, `sleep <max_lifetime_seconds>`, and real work arrives later through `exec_run`. When the sleep ends, the container stops on its own, even if your Python process crashed. This is a maximum lifetime, not an idle timeout (Docker has no idle TTL), so raise `max_lifetime_seconds` for long sessions.
224
+
225
+ ### Hardening
226
+
227
+ Containers are hardened by default, to limit the damage an agent can do to the host by accident or by mistake. Every setting is a `ContainerSpec` field.
228
+
229
+ | Setting | Default | Protects against | What the agent sees when it hits the limit |
230
+ |---------|---------|------------------|--------------------------------------------|
231
+ | `mem_limit` (plus equal swap cap) | `2g` | A runaway allocation exhausting host memory | The process is killed: `Killed`, exit code `137` |
232
+ | `pids_limit` | `512` | Fork bombs exhausting the host's process table | `Cannot fork` errors from the shell |
233
+ | `cpus` | `2.0` | One container starving the rest of the host | Commands run slower, so they reach their timeout sooner |
234
+ | `network_mode` | `none` | Reaching your LAN, cloud metadata endpoints, and the internet | Name-resolution or connection errors, for example `Temporary failure in name resolution` |
235
+ | `no_new_privileges` | on | Privilege escalation through setuid binaries such as `sudo` | `sudo` and similar tools stop working |
236
+ | `cap_drop_all` | on | Kernel attack surface reachable through Linux capabilities | `Permission denied` on some root-only operations |
237
+
238
+ Mention these limits in your system prompt or tool description, so the model can tell an exceeded limit from a bug in its own code.
239
+
240
+ **Adjusting the defaults.** Set a field to `None` or `False` to remove a restriction, for example `ContainerSpec(image=IMAGE, mem_limit="8g", pids_limit=None)`. `pids_limit=None` sends an explicit unlimited value, so a daemon-wide default (Podman applies 2048, for example) cannot apply. For the network, `network_mode="bridge"` allows access, which reaches your LAN, cloud metadata endpoints, and the internet, and `None` leaves the daemon's default. `"host"` shares the host's network stack and removes the isolation entirely. With all capabilities dropped, root inside the container loses some abilities. The most likely surprise is writing to a mounted host directory owned by another user, which needs `DAC_OVERRIDE`. `apt-get` needs `SETUID`, `SETGID`, and `CHOWN`, plus a `network_mode` that allows network access. Add back only what you need with `cap_add=("CHOWN", "DAC_OVERRIDE")`.
241
+
242
+ **Verifying the setup.** Confirm that Docker applied the settings:
243
+
244
+ ```python
245
+ sandbox.execute("grep -E 'CapEff|NoNewPrivs' /proc/self/status")
246
+ # CapEff: 0000000000000000, NoNewPrivs: 1 (with the default empty cap_add)
247
+ ```
248
+
249
+ ### Finding orphans
250
+
251
+ Every container created by this library carries the label `created-by=agent-container-sandbox`, even if you pass no labels. To list leftovers:
252
+
253
+ ```bash
254
+ docker ps -a --filter label=created-by=agent-container-sandbox
255
+ ```
256
+
257
+ ## Execution behavior
258
+
259
+ ### `ContainerSandbox`
260
+
261
+ | Parameter | Type | Default | Description |
262
+ |-----------|------|---------|-------------|
263
+ | `container` | `Container` | required | A running container. You own its lifecycle. |
264
+ | `working_dir` | `str \| None` | container's configured working directory (`/` if none) | Directory for commands and relative paths. If omitted, it is read from the container's configuration, which for provisioned containers is `ContainerSpec.working_dir`. If you pass it explicitly, it must already exist. |
265
+ | `command_timeout` | `int` | `150` | Default per-command timeout in seconds. Must be positive. |
266
+ | `max_output_size` | `int` | `65536` | Output beyond this many bytes is truncated. Must be positive. |
267
+
268
+ A non-positive `command_timeout` or `max_output_size` raises `ValueError` in the constructor, before any call to Docker. `execute()` returns the deepagents `ExecuteResponse` (`output`, `exit_code`, `truncated`).
269
+
270
+ - **`timeout` must be positive.** A per-call `timeout` of zero or less is rejected without running anything: `execute()` returns exit code `1` and a `Sandbox error:` message that names the value. Zero does not mean "no limit" here. For long-running commands, pass a large value instead.
271
+ - **Timeouts are enforced inside the container.** Each command runs as `timeout -k 2 N sh -c <command>`: SIGTERM at the deadline, SIGKILL 2 seconds later if the process is still running. The exit code is `124` after a normal timeout. It is `137` when SIGKILL was needed, which is also the code for an out-of-memory kill. Busybox-based images such as Alpine report `143` instead, without the "timed out" note. Processes that detach (`setsid`, double fork) can outlive `timeout`. Deleting the container is the only hard guarantee.
272
+ - **Docker-level hangs are reported separately.** If Docker itself does not answer within the timeout plus 7 seconds, you get exit code `124` and a message that the command's state is unknown and the sandbox may be unhealthy. The command may still be running.
273
+ - **Output is truncated by bytes**, without splitting a UTF-8 character, and `truncated` is set to `True`. Output is read fully into memory first, so avoid commands that print gigabytes.
274
+ - **Errors raised by the sandbox itself** are prefixed with `Sandbox error:` in `output`, to tell them apart from errors printed by your command.
275
+
276
+ `download_files` reports problems per file in `FileDownloadResponse.error`: `"file_not_found"`, `"is_directory"`, or a message string for anything else. A container that no longer exists produces a message string, not `"file_not_found"`. `upload_files` reports failures in `FileUploadResponse.error`. Neither raises for a single bad file.
277
+
278
+ `upload_files` never replaces an existing directory. If the target path is a directory, that file gets a per-file error and the directory is left untouched.
279
+
280
+ ### Checking a container
281
+
282
+ Every `execute()` call needs `timeout` inside the container. `ContainerProvisioner` does not check for it, because its own keep-alive command does not use it. If your image might lack `timeout`, or you hand `ContainerSandbox` a container you did not provision, check once up front:
283
+
284
+ ```python
285
+ sandbox = ContainerSandbox(container)
286
+ sandbox.verify_timeout() # raises MissingTimeoutError if `timeout` is unusable
287
+ sandbox.verify_python() # raises MissingPythonError if `python3` is unusable
288
+ ```
289
+
290
+ Without the `timeout` check, every `execute()` call fails with exit code `127` and an "executable file not found in $PATH" message from the container runtime in `output`. `execute()` does not raise, so an agent would see this on every command and may misread it as a problem with its own code. `verify_timeout()` turns it into a single `MissingTimeoutError` at setup time.
291
+
292
+ `verify_python()` matters for the file helpers `read`, `write`, `edit`, `ls` and `glob`, which run Python inside the container. Without it, `ls` returns an empty list instead of an error, so an agent can conclude that a directory is empty. `execute()` and file transfer do not need Python, so `verify_python()` is a separate, optional call.
293
+
294
+ `ManagedContainerSandbox` runs both checks on entry by default, `timeout` first. Turn either off with `verify_timeout=False` or `verify_python=False`.
295
+
296
+ ## Errors
297
+
298
+ Everything the library raises on purpose inherits from `SandboxError`:
299
+
300
+ ```
301
+ SandboxError
302
+ ├── DockerImageNotFound image missing and could not be pulled (including auth or rate-limit failures); has an .image attribute
303
+ ├── MissingTimeoutError the container cannot run commands under `timeout`
304
+ ├── MissingPythonError the container has no usable `python3`
305
+ └── ContainerReuseError get_or_create found the name but will not hand the container out; has .name and .container attributes
306
+ ├── SpecMismatchError it is not the container this spec describes; has a .reason attribute
307
+ └── ContainerNotRunningError it is the right container, but it is not running; has a .status attribute
308
+ ```
309
+
310
+ ```python
311
+ from agent_container_sandbox import DockerImageNotFound
312
+
313
+ try:
314
+ container = provisioner.provision(spec)
315
+ except DockerImageNotFound as e:
316
+ print(f"Could not get image {e.image}: {e}")
317
+ ```
318
+
319
+ Other failures, such as a container that cannot start or a daemon that is unreachable, surface as the underlying `docker-py` exceptions. Entering a `ManagedContainerSandbox` twice raises a plain `RuntimeError`, and a non-positive `command_timeout` or `max_output_size` raises a plain `ValueError`, because both are programming errors.
320
+
321
+ ## Known limitations
322
+
323
+ - **`get_or_create` checks the spec, not the live container.** It verifies that the container carries the library's marker label and the fingerprint of the spec, and that it is running. It cannot see settings changed on a live container (for example with `docker update`), a mutable image tag such as `latest` that now points to different content, or a later library version translating the same spec into different Docker arguments.
324
+ - **`get_or_create` is not safe against races.** Two callers creating the same name at the same moment can conflict.
325
+ - **`ls` and `grep` do not report clipped output.** When a listing or a search exceeds `max_output_size`, the deepagents helpers return the part that fit without any signal that results are missing, so the answer looks complete (observed with deepagents 0.7.19 and 0.7.32, reported as [deepagents#7480](https://github.com/langchain-ai/deepagents/issues/7480)). `glob` and `execute` do report truncation. Raise `max_output_size` for large directories or trees.
326
+ - **Network access is all or nothing.** The library can switch networking on or off but cannot restrict it to specific hosts. If the agent needs some access but not all of it, attach the container to a dedicated Docker network or use firewall rules.
327
+ - **Mounted directories are exposed.** A read-write volume gives the agent full access to its contents. Mount a dedicated directory, never a home directory, and never the Docker socket. Likewise, if your own code runs in a container, mounting the host socket gives that container control of the host's Docker daemon.
328
+ - **Commands run as the image's default user,** which is usually root inside the container. Capabilities are dropped, but the container is not rootless.
329
+ - **Disk usage is not limited.** The container's writable layer can grow until the host's disk is full.
330
+ - **Docker is not a hard security boundary.** Containers share the host kernel, so a kernel vulnerability bypasses all of the settings above. For untrusted code, add a stronger runtime such as gVisor or Kata, or run Docker in rootless mode.
331
+
332
+ ## License
333
+
334
+ MIT, see LICENSE
@@ -0,0 +1,10 @@
1
+ agent_container_sandbox/__init__.py,sha256=0hw7kL7HiCvM7Z-zgxvA4JjuASWt393uuh_g5nopGjg,960
2
+ agent_container_sandbox/errors.py,sha256=XIUCvnQs09r0ec77pCz52zfAetGrio8RF_MQW65mqoo,4994
3
+ agent_container_sandbox/managed.py,sha256=WLrn4mc0rXCj1HLRWHlSE7HHbfy2w8vOEqYvZ6tpxOA,6388
4
+ agent_container_sandbox/provisioning.py,sha256=8JkMNUWRjv5__vigRab3ZVwJrB7AJ4lDSxs49wCn9Rc,11729
5
+ agent_container_sandbox/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ agent_container_sandbox/sandbox.py,sha256=u0NqlNovpqMpX21R6xp1EO3OdMU-p2IrcL44G40c3Qc,12964
7
+ agent_container_sandbox-0.1.1.dist-info/licenses/LICENSE,sha256=HvdJk-UfUM_ilrrHXS86YzY5Q44wsSKAwxEve2eLyJw,1063
8
+ agent_container_sandbox-0.1.1.dist-info/WHEEL,sha256=xDCZ-UyfvkGuEHPeI7BcJzYKIZzdqN8A8o1M5Om8IyA,79
9
+ agent_container_sandbox-0.1.1.dist-info/METADATA,sha256=k8ptfNsifiJsLRZVQF-PwILZZEcexHtd8uzmIYE8_pM,23053
10
+ agent_container_sandbox-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.17
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Konovor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.