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.
- agent_container_sandbox/__init__.py +36 -0
- agent_container_sandbox/errors.py +132 -0
- agent_container_sandbox/managed.py +161 -0
- agent_container_sandbox/provisioning.py +273 -0
- agent_container_sandbox/py.typed +0 -0
- agent_container_sandbox/sandbox.py +310 -0
- agent_container_sandbox-0.1.1.dist-info/METADATA +334 -0
- agent_container_sandbox-0.1.1.dist-info/RECORD +10 -0
- agent_container_sandbox-0.1.1.dist-info/WHEEL +4 -0
- agent_container_sandbox-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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.
|