stirrup 0.1.0__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.
- stirrup/__init__.py +76 -0
- stirrup/clients/__init__.py +14 -0
- stirrup/clients/chat_completions_client.py +219 -0
- stirrup/clients/litellm_client.py +141 -0
- stirrup/clients/utils.py +161 -0
- stirrup/constants.py +14 -0
- stirrup/core/__init__.py +1 -0
- stirrup/core/agent.py +1097 -0
- stirrup/core/exceptions.py +7 -0
- stirrup/core/models.py +599 -0
- stirrup/prompts/__init__.py +22 -0
- stirrup/prompts/base_system_prompt.txt +1 -0
- stirrup/prompts/message_summarizer.txt +27 -0
- stirrup/prompts/message_summarizer_bridge.txt +11 -0
- stirrup/py.typed +0 -0
- stirrup/tools/__init__.py +77 -0
- stirrup/tools/calculator.py +32 -0
- stirrup/tools/code_backends/__init__.py +38 -0
- stirrup/tools/code_backends/base.py +454 -0
- stirrup/tools/code_backends/docker.py +752 -0
- stirrup/tools/code_backends/e2b.py +359 -0
- stirrup/tools/code_backends/local.py +481 -0
- stirrup/tools/finish.py +23 -0
- stirrup/tools/mcp.py +500 -0
- stirrup/tools/view_image.py +83 -0
- stirrup/tools/web.py +336 -0
- stirrup/utils/__init__.py +10 -0
- stirrup/utils/logging.py +944 -0
- stirrup/utils/text.py +11 -0
- stirrup-0.1.0.dist-info/METADATA +318 -0
- stirrup-0.1.0.dist-info/RECORD +32 -0
- stirrup-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
"""Docker container execution environment backend for code execution."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Self
|
|
10
|
+
|
|
11
|
+
from anyio import fail_after, to_thread
|
|
12
|
+
from dotenv import load_dotenv
|
|
13
|
+
|
|
14
|
+
from stirrup.core.models import ImageContentBlock
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import docker
|
|
18
|
+
from docker.client import DockerClient
|
|
19
|
+
from docker.errors import APIError, BuildError, ImageNotFound, NotFound
|
|
20
|
+
from docker.models.containers import Container
|
|
21
|
+
except ImportError as e:
|
|
22
|
+
raise ImportError(
|
|
23
|
+
"Requires installation of the docker extra. Install with (for example): `uv pip install stirrup[docker]` or `uv add stirrup[docker]`",
|
|
24
|
+
) from e
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
|
|
28
|
+
from stirrup.core.models import Tool, ToolUseCountMetadata
|
|
29
|
+
|
|
30
|
+
from .base import (
|
|
31
|
+
SHELL_TIMEOUT,
|
|
32
|
+
CodeExecToolProvider,
|
|
33
|
+
CodeExecutionParams,
|
|
34
|
+
CommandResult,
|
|
35
|
+
SavedFile,
|
|
36
|
+
SaveOutputFilesResult,
|
|
37
|
+
UploadedFile,
|
|
38
|
+
UploadFilesResult,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
DEFAULT_WORKING_DIR = "/workspace"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DockerCodeExecToolProvider(CodeExecToolProvider):
|
|
47
|
+
"""Docker container code execution tool provider.
|
|
48
|
+
|
|
49
|
+
Creates a persistent Docker container with a host directory mounted
|
|
50
|
+
as a volume. Commands are executed via docker exec, and files persist
|
|
51
|
+
between commands within the same session.
|
|
52
|
+
|
|
53
|
+
Usage:
|
|
54
|
+
# From pre-built image
|
|
55
|
+
provider = DockerCodeExecToolProvider.from_image("python:3.12-slim")
|
|
56
|
+
|
|
57
|
+
# From Dockerfile
|
|
58
|
+
provider = DockerCodeExecToolProvider.from_dockerfile(Path("./Dockerfile"))
|
|
59
|
+
|
|
60
|
+
# With command allowlist
|
|
61
|
+
provider = DockerCodeExecToolProvider.from_image(
|
|
62
|
+
"python:3.12-slim",
|
|
63
|
+
allowed_commands=[r"^python", r"^pip"],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# With Agent
|
|
67
|
+
from stirrup.clients.chat_completions_client import ChatCompletionsClient
|
|
68
|
+
|
|
69
|
+
client = ChatCompletionsClient(model="gpt-5")
|
|
70
|
+
agent = Agent(client=client, name="assistant", tools=[provider])
|
|
71
|
+
async with agent.session() as session:
|
|
72
|
+
await session.run("Run Python code")
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
source: str | Path,
|
|
78
|
+
*,
|
|
79
|
+
is_dockerfile: bool = False,
|
|
80
|
+
dockerfile_context: Path | None = None,
|
|
81
|
+
working_dir: str = DEFAULT_WORKING_DIR,
|
|
82
|
+
allowed_commands: list[str] | None = None,
|
|
83
|
+
temp_base_dir: Path | None = None,
|
|
84
|
+
env_vars: list[str] | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Initialize DockerCodeExecToolProvider configuration.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
source: Docker image name (e.g., "python:3.12-slim") or path to Dockerfile.
|
|
90
|
+
is_dockerfile: If True, source is treated as a Dockerfile path. Default False.
|
|
91
|
+
dockerfile_context: Build context directory for Dockerfile builds.
|
|
92
|
+
working_dir: Container working directory (default: /workspace).
|
|
93
|
+
allowed_commands: Optional regex patterns for command allowlist.
|
|
94
|
+
temp_base_dir: Optional host base directory for temp files.
|
|
95
|
+
env_vars: Optional list of environment variable names to inject into the
|
|
96
|
+
container. Values are loaded from the current environment (os.environ)
|
|
97
|
+
after calling load_dotenv() to load any .env file.
|
|
98
|
+
|
|
99
|
+
Prefer using the factory methods for clarity:
|
|
100
|
+
- DockerCodeExecToolProvider.from_image() for pre-built images
|
|
101
|
+
- DockerCodeExecToolProvider.from_dockerfile() for building from Dockerfile
|
|
102
|
+
|
|
103
|
+
"""
|
|
104
|
+
super().__init__(allowed_commands=allowed_commands)
|
|
105
|
+
|
|
106
|
+
self._source = source
|
|
107
|
+
self._is_dockerfile = is_dockerfile
|
|
108
|
+
self._dockerfile_context = dockerfile_context
|
|
109
|
+
self._working_dir = working_dir
|
|
110
|
+
self._temp_base_dir = temp_base_dir
|
|
111
|
+
self._env_vars = env_vars
|
|
112
|
+
|
|
113
|
+
# Runtime state
|
|
114
|
+
self._temp_dir: Path | None = None
|
|
115
|
+
self._client: DockerClient | None = None
|
|
116
|
+
self._container: Container | None = None
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def temp_dir(self) -> Path | None:
|
|
120
|
+
"""Return the host temp directory path, or None if not started."""
|
|
121
|
+
return self._temp_dir
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def container_id(self) -> str | None:
|
|
125
|
+
"""Return the container short ID, or None if not started."""
|
|
126
|
+
return self._container.short_id if self._container else None
|
|
127
|
+
|
|
128
|
+
def _resolve_file_path(self, path: str) -> Path:
|
|
129
|
+
"""Resolve a container path string to a validated host file path.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
path: Path to file (relative to working directory, or absolute container path).
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Resolved absolute host Path to the file.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
RuntimeError: If execution environment not started.
|
|
139
|
+
ValueError: If path is outside mounted directory or is not a file.
|
|
140
|
+
FileNotFoundError: If file does not exist.
|
|
141
|
+
|
|
142
|
+
"""
|
|
143
|
+
if self._temp_dir is None:
|
|
144
|
+
raise RuntimeError("ExecutionEnvironment not started. Use 'async with exec_env.create()' first.")
|
|
145
|
+
|
|
146
|
+
file_path = Path(path)
|
|
147
|
+
|
|
148
|
+
# Handle both absolute container paths and relative paths
|
|
149
|
+
if file_path.is_absolute():
|
|
150
|
+
# Convert container absolute path to host path
|
|
151
|
+
# e.g., /workspace/image.png -> <temp_dir>/image.png
|
|
152
|
+
if str(file_path).startswith(self._working_dir):
|
|
153
|
+
relative = file_path.relative_to(self._working_dir)
|
|
154
|
+
file_path = self._temp_dir / relative
|
|
155
|
+
else:
|
|
156
|
+
raise ValueError(f"Path is outside mounted directory: {path}")
|
|
157
|
+
else:
|
|
158
|
+
file_path = self._temp_dir / file_path
|
|
159
|
+
|
|
160
|
+
# Security check: ensure path is within temp directory
|
|
161
|
+
try:
|
|
162
|
+
file_path.resolve().relative_to(self._temp_dir.resolve())
|
|
163
|
+
except ValueError:
|
|
164
|
+
raise ValueError(f"Path is outside execution environment directory: {path}") from None
|
|
165
|
+
|
|
166
|
+
if not file_path.exists():
|
|
167
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
168
|
+
if not file_path.is_file():
|
|
169
|
+
raise ValueError(f"Path is not a file: {path}")
|
|
170
|
+
|
|
171
|
+
return file_path
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def from_image(
|
|
175
|
+
cls,
|
|
176
|
+
image: str,
|
|
177
|
+
*,
|
|
178
|
+
working_dir: str = DEFAULT_WORKING_DIR,
|
|
179
|
+
allowed_commands: list[str] | None = None,
|
|
180
|
+
temp_base_dir: Path | str | None = None,
|
|
181
|
+
env_vars: list[str] | None = None,
|
|
182
|
+
) -> Self:
|
|
183
|
+
"""Create tool provider from a pre-built Docker image.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
image: Docker image name (e.g., "python:3.12-slim").
|
|
187
|
+
working_dir: Container working directory (default: /workspace).
|
|
188
|
+
allowed_commands: Optional regex patterns for command allowlist.
|
|
189
|
+
temp_base_dir: Optional host base directory for temp files.
|
|
190
|
+
env_vars: Optional list of environment variable names to inject into the
|
|
191
|
+
container. Values are loaded from os.environ (after load_dotenv()).
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Configured DockerCodeExecToolProvider instance.
|
|
195
|
+
|
|
196
|
+
Example:
|
|
197
|
+
provider = DockerCodeExecToolProvider.from_image(
|
|
198
|
+
"python:3.12-slim",
|
|
199
|
+
env_vars=["OPENROUTER_API_KEY", "DATABASE_URL"],
|
|
200
|
+
)
|
|
201
|
+
async with provider as tool:
|
|
202
|
+
result = await provider.run_command("python --version")
|
|
203
|
+
|
|
204
|
+
"""
|
|
205
|
+
return cls(
|
|
206
|
+
image,
|
|
207
|
+
is_dockerfile=False,
|
|
208
|
+
working_dir=working_dir,
|
|
209
|
+
allowed_commands=allowed_commands,
|
|
210
|
+
temp_base_dir=Path(temp_base_dir) if temp_base_dir else None,
|
|
211
|
+
env_vars=env_vars,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
@classmethod
|
|
215
|
+
def from_dockerfile(
|
|
216
|
+
cls,
|
|
217
|
+
dockerfile: Path | str,
|
|
218
|
+
*,
|
|
219
|
+
context: Path | str | None = None,
|
|
220
|
+
working_dir: str = DEFAULT_WORKING_DIR,
|
|
221
|
+
allowed_commands: list[str] | None = None,
|
|
222
|
+
temp_base_dir: Path | str | None = None,
|
|
223
|
+
env_vars: list[str] | None = None,
|
|
224
|
+
) -> Self:
|
|
225
|
+
"""Create tool provider by building from a Dockerfile.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
dockerfile: Path to the Dockerfile.
|
|
229
|
+
context: Build context directory. Defaults to Dockerfile's parent.
|
|
230
|
+
working_dir: Container working directory (default: /workspace).
|
|
231
|
+
allowed_commands: Optional regex patterns for command allowlist.
|
|
232
|
+
temp_base_dir: Optional host base directory for temp files.
|
|
233
|
+
env_vars: Optional list of environment variable names to inject into the
|
|
234
|
+
container. Values are loaded from os.environ (after load_dotenv()).
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
Configured DockerCodeExecToolProvider instance.
|
|
238
|
+
|
|
239
|
+
Example:
|
|
240
|
+
provider = DockerCodeExecToolProvider.from_dockerfile(
|
|
241
|
+
Path("./Dockerfile"),
|
|
242
|
+
env_vars=["OPENROUTER_API_KEY", "ANTHROPIC_API_KEY"],
|
|
243
|
+
)
|
|
244
|
+
async with provider as tool:
|
|
245
|
+
result = await provider.run_command("python script.py")
|
|
246
|
+
|
|
247
|
+
"""
|
|
248
|
+
return cls(
|
|
249
|
+
dockerfile,
|
|
250
|
+
is_dockerfile=True,
|
|
251
|
+
dockerfile_context=Path(context) if context else None,
|
|
252
|
+
working_dir=working_dir,
|
|
253
|
+
allowed_commands=allowed_commands,
|
|
254
|
+
temp_base_dir=Path(temp_base_dir) if temp_base_dir else None,
|
|
255
|
+
env_vars=env_vars,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
async def __aenter__(self) -> Tool[CodeExecutionParams, ToolUseCountMetadata]:
|
|
259
|
+
"""Initialize Docker container and return the code_exec tool.
|
|
260
|
+
|
|
261
|
+
Creates a temp directory on the host, initializes the Docker client,
|
|
262
|
+
prepares the image (pull or build), and starts a persistent container
|
|
263
|
+
with the temp directory mounted as a volume.
|
|
264
|
+
"""
|
|
265
|
+
# 1. Load environment variables from .env file
|
|
266
|
+
load_dotenv()
|
|
267
|
+
|
|
268
|
+
# 2. Build environment dict from requested env var names
|
|
269
|
+
env_dict: dict[str, str] = {}
|
|
270
|
+
if self._env_vars:
|
|
271
|
+
for name in self._env_vars:
|
|
272
|
+
if name in os.environ:
|
|
273
|
+
env_dict[name] = os.environ[name]
|
|
274
|
+
else:
|
|
275
|
+
logger.warning("Requested env var '%s' not found in environment", name)
|
|
276
|
+
if env_dict:
|
|
277
|
+
logger.debug("Injecting environment variables: %s", list(env_dict.keys()))
|
|
278
|
+
|
|
279
|
+
# 3. Create temp directory on host
|
|
280
|
+
if self._temp_base_dir:
|
|
281
|
+
self._temp_base_dir.mkdir(parents=True, exist_ok=True)
|
|
282
|
+
self._temp_dir = Path(tempfile.mkdtemp(prefix="docker_exec_env_", dir=self._temp_base_dir))
|
|
283
|
+
|
|
284
|
+
# 4. Initialize Docker client
|
|
285
|
+
self._client = await to_thread.run_sync(docker.from_env)
|
|
286
|
+
if self._client is None:
|
|
287
|
+
raise RuntimeError("Failed to connect to Docker daemon. Is Docker running?")
|
|
288
|
+
client = self._client # Capture for lambda type narrowing
|
|
289
|
+
|
|
290
|
+
# 5. Prepare image (pull or build)
|
|
291
|
+
image_name = await self._prepare_image()
|
|
292
|
+
|
|
293
|
+
# 6. Start container with volume mount and environment variables
|
|
294
|
+
self._container = await to_thread.run_sync(
|
|
295
|
+
lambda: client.containers.run(
|
|
296
|
+
image_name,
|
|
297
|
+
command="tail -f /dev/null", # Keep container running
|
|
298
|
+
detach=True,
|
|
299
|
+
volumes={
|
|
300
|
+
str(self._temp_dir): {
|
|
301
|
+
"bind": self._working_dir,
|
|
302
|
+
"mode": "rw",
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
working_dir=self._working_dir,
|
|
306
|
+
environment=env_dict if env_dict else None,
|
|
307
|
+
remove=False, # We handle removal manually
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
logger.info("Started container: %s (image: %s)", self._container.short_id, image_name)
|
|
311
|
+
return self.get_code_exec_tool()
|
|
312
|
+
|
|
313
|
+
async def _prepare_image(self) -> str:
|
|
314
|
+
"""Prepare Docker image (pull pre-built or build from Dockerfile).
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
The image name/tag to use for container creation.
|
|
318
|
+
|
|
319
|
+
Raises:
|
|
320
|
+
RuntimeError: If image build or pull fails.
|
|
321
|
+
|
|
322
|
+
"""
|
|
323
|
+
if self._client is None:
|
|
324
|
+
raise RuntimeError("Docker client not initialized")
|
|
325
|
+
client = self._client # Capture for lambda type narrowing
|
|
326
|
+
|
|
327
|
+
if self._is_dockerfile:
|
|
328
|
+
# Build from Dockerfile
|
|
329
|
+
dockerfile_path = Path(self._source).resolve()
|
|
330
|
+
context_path = self._dockerfile_context.resolve() if self._dockerfile_context else dockerfile_path.parent
|
|
331
|
+
|
|
332
|
+
# Generate unique tag based on dockerfile path
|
|
333
|
+
tag = f"agent001-exec-env-{hashlib.md5(str(dockerfile_path).encode()).hexdigest()[:8]}"
|
|
334
|
+
|
|
335
|
+
logger.info("Building image from %s with tag %s", dockerfile_path, tag)
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
# Determine dockerfile path relative to context
|
|
339
|
+
if dockerfile_path.is_relative_to(context_path):
|
|
340
|
+
dockerfile_rel = str(dockerfile_path.relative_to(context_path))
|
|
341
|
+
else:
|
|
342
|
+
dockerfile_rel = str(dockerfile_path)
|
|
343
|
+
|
|
344
|
+
_image, build_logs = await to_thread.run_sync(
|
|
345
|
+
lambda: client.images.build(
|
|
346
|
+
path=str(context_path),
|
|
347
|
+
dockerfile=dockerfile_rel,
|
|
348
|
+
tag=tag,
|
|
349
|
+
rm=True, # Remove intermediate containers
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
for log in build_logs:
|
|
353
|
+
if "stream" in log:
|
|
354
|
+
logger.debug("Build: %s", log["stream"].strip())
|
|
355
|
+
return tag
|
|
356
|
+
except BuildError as exc:
|
|
357
|
+
raise RuntimeError(f"Failed to build Docker image: {exc}") from exc
|
|
358
|
+
else:
|
|
359
|
+
# Pull pre-built image
|
|
360
|
+
image_name = str(self._source)
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
# Check if image exists locally
|
|
364
|
+
await to_thread.run_sync(client.images.get, image_name)
|
|
365
|
+
logger.debug("Image %s found locally", image_name)
|
|
366
|
+
except ImageNotFound:
|
|
367
|
+
logger.info("Pulling image: %s", image_name)
|
|
368
|
+
try:
|
|
369
|
+
await to_thread.run_sync(client.images.pull, image_name)
|
|
370
|
+
except APIError as exc:
|
|
371
|
+
raise RuntimeError(f"Failed to pull Docker image '{image_name}': {exc}") from exc
|
|
372
|
+
|
|
373
|
+
return image_name
|
|
374
|
+
|
|
375
|
+
async def __aexit__(
|
|
376
|
+
self,
|
|
377
|
+
exc_type: type[BaseException] | None,
|
|
378
|
+
exc_val: BaseException | None,
|
|
379
|
+
exc_tb: object,
|
|
380
|
+
) -> None:
|
|
381
|
+
"""Stop container and cleanup temp directory."""
|
|
382
|
+
# Stop and remove container
|
|
383
|
+
if self._container:
|
|
384
|
+
container = self._container # Capture for lambda type narrowing
|
|
385
|
+
try:
|
|
386
|
+
logger.info("Stopping container: %s", container.short_id)
|
|
387
|
+
await to_thread.run_sync(lambda: container.stop(timeout=10))
|
|
388
|
+
await to_thread.run_sync(lambda: container.remove(force=True))
|
|
389
|
+
logger.info("Removed container: %s", container.short_id)
|
|
390
|
+
except NotFound:
|
|
391
|
+
logger.debug("Container already removed")
|
|
392
|
+
except Exception as exc:
|
|
393
|
+
logger.warning("Failed to cleanup container: %s", exc)
|
|
394
|
+
self._container = None
|
|
395
|
+
|
|
396
|
+
# Close Docker client
|
|
397
|
+
if self._client:
|
|
398
|
+
with contextlib.suppress(Exception):
|
|
399
|
+
await to_thread.run_sync(self._client.close)
|
|
400
|
+
self._client = None
|
|
401
|
+
|
|
402
|
+
# Cleanup temp directory
|
|
403
|
+
if self._temp_dir and self._temp_dir.exists():
|
|
404
|
+
try:
|
|
405
|
+
shutil.rmtree(self._temp_dir)
|
|
406
|
+
except Exception as exc:
|
|
407
|
+
logger.warning("Failed to cleanup temp directory %s: %s", self._temp_dir, exc)
|
|
408
|
+
self._temp_dir = None
|
|
409
|
+
|
|
410
|
+
def _container_path_to_host(self, path: str) -> Path:
|
|
411
|
+
"""Convert a container path to the corresponding host path.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
path: Path in the container (relative or absolute).
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
Resolved Path on the host filesystem.
|
|
418
|
+
|
|
419
|
+
Raises:
|
|
420
|
+
RuntimeError: If environment not started.
|
|
421
|
+
ValueError: If path is outside the mounted directory.
|
|
422
|
+
|
|
423
|
+
"""
|
|
424
|
+
if self._temp_dir is None:
|
|
425
|
+
raise RuntimeError("ExecutionEnvironment not started.")
|
|
426
|
+
|
|
427
|
+
source_path = Path(path)
|
|
428
|
+
|
|
429
|
+
# Handle both absolute container paths and relative paths
|
|
430
|
+
if source_path.is_absolute():
|
|
431
|
+
# Convert container absolute path to host path
|
|
432
|
+
# e.g., /workspace/output.txt -> <temp_dir>/output.txt
|
|
433
|
+
if str(source_path).startswith(self._working_dir):
|
|
434
|
+
relative = source_path.relative_to(self._working_dir)
|
|
435
|
+
host_path = self._temp_dir / relative
|
|
436
|
+
else:
|
|
437
|
+
raise ValueError(f"Path is outside mounted directory: {path}")
|
|
438
|
+
else:
|
|
439
|
+
host_path = self._temp_dir / source_path
|
|
440
|
+
|
|
441
|
+
# Security: ensure path is within temp directory
|
|
442
|
+
try:
|
|
443
|
+
host_path.resolve().relative_to(self._temp_dir.resolve())
|
|
444
|
+
except ValueError as e:
|
|
445
|
+
raise ValueError(f"Path is outside execution environment: {path}") from e
|
|
446
|
+
|
|
447
|
+
return host_path
|
|
448
|
+
|
|
449
|
+
async def read_file_bytes(self, path: str) -> bytes:
|
|
450
|
+
"""Read file content as bytes from the container.
|
|
451
|
+
|
|
452
|
+
Since files are volume-mounted, reads directly from the host temp directory.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
path: File path (relative or absolute container path).
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
File contents as bytes.
|
|
459
|
+
|
|
460
|
+
Raises:
|
|
461
|
+
RuntimeError: If environment not started.
|
|
462
|
+
ValueError: If path is outside mounted directory.
|
|
463
|
+
FileNotFoundError: If file does not exist.
|
|
464
|
+
|
|
465
|
+
"""
|
|
466
|
+
host_path = self._container_path_to_host(path)
|
|
467
|
+
if not host_path.exists():
|
|
468
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
469
|
+
return host_path.read_bytes()
|
|
470
|
+
|
|
471
|
+
async def write_file_bytes(self, path: str, content: bytes) -> None:
|
|
472
|
+
"""Write bytes to a file in the container.
|
|
473
|
+
|
|
474
|
+
Since files are volume-mounted, writes directly to the host temp directory.
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
path: Destination path (relative or absolute container path).
|
|
478
|
+
content: File contents to write.
|
|
479
|
+
|
|
480
|
+
Raises:
|
|
481
|
+
RuntimeError: If environment not started.
|
|
482
|
+
ValueError: If path is outside mounted directory.
|
|
483
|
+
|
|
484
|
+
"""
|
|
485
|
+
host_path = self._container_path_to_host(path)
|
|
486
|
+
host_path.parent.mkdir(parents=True, exist_ok=True)
|
|
487
|
+
host_path.write_bytes(content)
|
|
488
|
+
|
|
489
|
+
async def run_command(self, cmd: str, *, timeout: int = SHELL_TIMEOUT) -> CommandResult:
|
|
490
|
+
"""Execute a shell command in the Docker container.
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
cmd: Shell command to execute (bash syntax).
|
|
494
|
+
timeout: Maximum time in seconds to wait for command completion.
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
CommandResult with exit_code, stdout, stderr, and optional error info.
|
|
498
|
+
|
|
499
|
+
"""
|
|
500
|
+
if self._container is None:
|
|
501
|
+
raise RuntimeError(
|
|
502
|
+
"ExecutionEnvironment not started. Ensure current Agent is equipped with a CodeExecToolProvider."
|
|
503
|
+
)
|
|
504
|
+
container = self._container # Capture for lambda type narrowing
|
|
505
|
+
|
|
506
|
+
# Check allowlist
|
|
507
|
+
if not self._check_allowed(cmd):
|
|
508
|
+
return CommandResult(
|
|
509
|
+
exit_code=1,
|
|
510
|
+
stdout="",
|
|
511
|
+
stderr=f"Command not allowed: '{cmd}' does not match any allowed patterns",
|
|
512
|
+
error_kind="command_not_allowed",
|
|
513
|
+
advice="Only commands matching the allowlist patterns are permitted.",
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
try:
|
|
517
|
+
# Execute command with timeout
|
|
518
|
+
with fail_after(timeout):
|
|
519
|
+
exec_result = await to_thread.run_sync(
|
|
520
|
+
lambda: container.exec_run(
|
|
521
|
+
cmd=["bash", "-c", cmd],
|
|
522
|
+
workdir=self._working_dir,
|
|
523
|
+
demux=True, # Separate stdout/stderr
|
|
524
|
+
)
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
exit_code = exec_result.exit_code
|
|
528
|
+
stdout_bytes, stderr_bytes = exec_result.output
|
|
529
|
+
|
|
530
|
+
return CommandResult(
|
|
531
|
+
exit_code=exit_code,
|
|
532
|
+
stdout=(stdout_bytes or b"").decode("utf-8", errors="replace"),
|
|
533
|
+
stderr=(stderr_bytes or b"").decode("utf-8", errors="replace"),
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
except TimeoutError:
|
|
537
|
+
logger.warning("Command timed out after %d seconds: %s", timeout, cmd[:100])
|
|
538
|
+
return CommandResult(
|
|
539
|
+
exit_code=1,
|
|
540
|
+
stdout="",
|
|
541
|
+
stderr=f"Command timed out after {timeout} seconds",
|
|
542
|
+
error_kind="timeout",
|
|
543
|
+
)
|
|
544
|
+
except APIError as exc:
|
|
545
|
+
return CommandResult(
|
|
546
|
+
exit_code=1,
|
|
547
|
+
stdout="",
|
|
548
|
+
stderr=str(exc),
|
|
549
|
+
error_kind="docker_api_error",
|
|
550
|
+
advice="Docker API error occurred. Check Docker daemon is running.",
|
|
551
|
+
)
|
|
552
|
+
except Exception as exc:
|
|
553
|
+
return CommandResult(
|
|
554
|
+
exit_code=1,
|
|
555
|
+
stdout="",
|
|
556
|
+
stderr=str(exc),
|
|
557
|
+
error_kind="execution_error",
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
async def save_output_files(
|
|
561
|
+
self,
|
|
562
|
+
paths: list[str],
|
|
563
|
+
output_dir: Path | str,
|
|
564
|
+
dest_env: "CodeExecToolProvider | None" = None,
|
|
565
|
+
) -> SaveOutputFilesResult:
|
|
566
|
+
"""Move files from the mounted temp directory to a destination.
|
|
567
|
+
|
|
568
|
+
Since files are volume-mounted, they're already on the host.
|
|
569
|
+
|
|
570
|
+
When dest_env is None (local filesystem), files are MOVED (not copied) -
|
|
571
|
+
originals are deleted from the execution environment.
|
|
572
|
+
Existing files in output_dir are silently overwritten.
|
|
573
|
+
|
|
574
|
+
When dest_env is provided (cross-environment transfer), files are copied
|
|
575
|
+
using the base class implementation via read/write primitives.
|
|
576
|
+
|
|
577
|
+
Args:
|
|
578
|
+
paths: List of file paths in the execution environment (relative or absolute container paths).
|
|
579
|
+
Relative paths are resolved against the container working directory.
|
|
580
|
+
Absolute container paths starting with working_dir are mapped to the host.
|
|
581
|
+
output_dir: Directory path to save files to.
|
|
582
|
+
dest_env: If provided, output_dir is interpreted as a path within dest_env
|
|
583
|
+
(cross-environment transfer). If None, output_dir is a local
|
|
584
|
+
filesystem path.
|
|
585
|
+
|
|
586
|
+
Returns:
|
|
587
|
+
SaveOutputFilesResult containing lists of saved files and any failures.
|
|
588
|
+
|
|
589
|
+
"""
|
|
590
|
+
if self._temp_dir is None:
|
|
591
|
+
raise RuntimeError(
|
|
592
|
+
"ExecutionEnvironment not started. Ensure current Agent is equipped with a CodeExecToolProvider."
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
# If dest_env is provided, use the base class implementation (cross-env transfer)
|
|
596
|
+
if dest_env is not None:
|
|
597
|
+
return await super().save_output_files(paths, output_dir, dest_env)
|
|
598
|
+
|
|
599
|
+
# Local filesystem - use optimized move operation
|
|
600
|
+
output_dir_path = Path(output_dir)
|
|
601
|
+
output_dir_path.mkdir(parents=True, exist_ok=True)
|
|
602
|
+
|
|
603
|
+
result = SaveOutputFilesResult()
|
|
604
|
+
|
|
605
|
+
for source_path_str in paths:
|
|
606
|
+
try:
|
|
607
|
+
host_path = self._container_path_to_host(source_path_str)
|
|
608
|
+
|
|
609
|
+
if not host_path.exists():
|
|
610
|
+
result.failed[source_path_str] = "File does not exist"
|
|
611
|
+
logger.warning("Execution environment file does not exist: %s", source_path_str)
|
|
612
|
+
continue
|
|
613
|
+
|
|
614
|
+
if not host_path.is_file():
|
|
615
|
+
result.failed[source_path_str] = "Path is not a file"
|
|
616
|
+
logger.warning("Execution environment path is not a file: %s", source_path_str)
|
|
617
|
+
continue
|
|
618
|
+
|
|
619
|
+
file_size = host_path.stat().st_size
|
|
620
|
+
dest_path = output_dir_path / host_path.name
|
|
621
|
+
|
|
622
|
+
# Move file (overwrites if exists)
|
|
623
|
+
shutil.move(str(host_path), str(dest_path))
|
|
624
|
+
|
|
625
|
+
result.saved.append(
|
|
626
|
+
SavedFile(
|
|
627
|
+
source_path=source_path_str,
|
|
628
|
+
output_path=dest_path,
|
|
629
|
+
size=file_size,
|
|
630
|
+
),
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
except ValueError as exc:
|
|
634
|
+
# Path validation error from _container_path_to_host
|
|
635
|
+
result.failed[source_path_str] = str(exc)
|
|
636
|
+
logger.warning("Path validation error: %s", exc)
|
|
637
|
+
except Exception as exc:
|
|
638
|
+
result.failed[source_path_str] = str(exc)
|
|
639
|
+
logger.exception("Failed to move file: %s", source_path_str)
|
|
640
|
+
|
|
641
|
+
return result
|
|
642
|
+
|
|
643
|
+
async def upload_files(
|
|
644
|
+
self,
|
|
645
|
+
*paths: Path | str,
|
|
646
|
+
source_env: "CodeExecToolProvider | None" = None,
|
|
647
|
+
dest_dir: str | None = None,
|
|
648
|
+
) -> UploadFilesResult:
|
|
649
|
+
"""Upload files to the execution environment.
|
|
650
|
+
|
|
651
|
+
Since files are volume-mounted, this copies files to the host temp directory
|
|
652
|
+
which makes them automatically visible in the container at working_dir.
|
|
653
|
+
|
|
654
|
+
When source_env is None (local filesystem), files are COPIED (not moved) -
|
|
655
|
+
originals remain on the local filesystem.
|
|
656
|
+
Directories are uploaded recursively, preserving their structure.
|
|
657
|
+
|
|
658
|
+
When source_env is provided (cross-environment transfer), files are copied
|
|
659
|
+
using the base class implementation via read/write primitives.
|
|
660
|
+
|
|
661
|
+
Args:
|
|
662
|
+
*paths: File or directory paths to upload. If source_env is None, these
|
|
663
|
+
are local filesystem paths. If source_env is provided, these are
|
|
664
|
+
paths within source_env.
|
|
665
|
+
source_env: If provided, paths are within source_env. If None, paths are
|
|
666
|
+
local filesystem paths.
|
|
667
|
+
dest_dir: Destination subdirectory within the container working directory.
|
|
668
|
+
If None, files are placed directly in the working directory.
|
|
669
|
+
|
|
670
|
+
Returns:
|
|
671
|
+
UploadFilesResult containing lists of uploaded files and any failures.
|
|
672
|
+
|
|
673
|
+
"""
|
|
674
|
+
if self._temp_dir is None:
|
|
675
|
+
raise RuntimeError(
|
|
676
|
+
"ExecutionEnvironment not started. Ensure current Agent is equipped with a CodeExecToolProvider."
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
# If source_env is provided, use the base class implementation (cross-env transfer)
|
|
680
|
+
if source_env is not None:
|
|
681
|
+
return await super().upload_files(*paths, source_env=source_env, dest_dir=dest_dir)
|
|
682
|
+
|
|
683
|
+
# Local filesystem - use optimized copy operation
|
|
684
|
+
dest_base = self._temp_dir / dest_dir if dest_dir else self._temp_dir
|
|
685
|
+
dest_base.mkdir(parents=True, exist_ok=True)
|
|
686
|
+
|
|
687
|
+
result = UploadFilesResult()
|
|
688
|
+
|
|
689
|
+
for source in paths:
|
|
690
|
+
source = Path(source).resolve()
|
|
691
|
+
|
|
692
|
+
if not source.exists():
|
|
693
|
+
result.failed[str(source)] = "File or directory does not exist"
|
|
694
|
+
logger.warning("Upload source does not exist: %s", source)
|
|
695
|
+
continue
|
|
696
|
+
|
|
697
|
+
try:
|
|
698
|
+
if source.is_file():
|
|
699
|
+
dest = dest_base / source.name
|
|
700
|
+
shutil.copy2(source, dest)
|
|
701
|
+
# Report path as it appears in container
|
|
702
|
+
container_path = f"{self._working_dir}/{dest.relative_to(self._temp_dir)}"
|
|
703
|
+
result.uploaded.append(
|
|
704
|
+
UploadedFile(
|
|
705
|
+
source_path=source,
|
|
706
|
+
dest_path=container_path,
|
|
707
|
+
size=source.stat().st_size,
|
|
708
|
+
),
|
|
709
|
+
)
|
|
710
|
+
logger.debug("Uploaded file: %s -> %s", source, container_path)
|
|
711
|
+
|
|
712
|
+
elif source.is_dir():
|
|
713
|
+
dest = dest_base / source.name
|
|
714
|
+
shutil.copytree(source, dest, dirs_exist_ok=True)
|
|
715
|
+
# Track all individual files uploaded
|
|
716
|
+
for file_path in source.rglob("*"):
|
|
717
|
+
if file_path.is_file():
|
|
718
|
+
relative = file_path.relative_to(source)
|
|
719
|
+
dest_file = dest / relative
|
|
720
|
+
container_path = f"{self._working_dir}/{dest_file.relative_to(self._temp_dir)}"
|
|
721
|
+
result.uploaded.append(
|
|
722
|
+
UploadedFile(
|
|
723
|
+
source_path=file_path,
|
|
724
|
+
dest_path=container_path,
|
|
725
|
+
size=file_path.stat().st_size,
|
|
726
|
+
),
|
|
727
|
+
)
|
|
728
|
+
logger.debug("Uploaded directory: %s -> %s/%s", source, self._working_dir, source.name)
|
|
729
|
+
|
|
730
|
+
except Exception as exc:
|
|
731
|
+
result.failed[str(source)] = str(exc)
|
|
732
|
+
logger.exception("Failed to upload: %s", source)
|
|
733
|
+
|
|
734
|
+
return result
|
|
735
|
+
|
|
736
|
+
async def view_image(self, path: str) -> ImageContentBlock:
|
|
737
|
+
"""Read and return an image file from the Docker execution environment.
|
|
738
|
+
|
|
739
|
+
Args:
|
|
740
|
+
path: Path to image file (relative to working directory, or absolute container path).
|
|
741
|
+
|
|
742
|
+
Returns:
|
|
743
|
+
ImageContentBlock containing the image data.
|
|
744
|
+
|
|
745
|
+
Raises:
|
|
746
|
+
RuntimeError: If execution environment not started.
|
|
747
|
+
FileNotFoundError: If file does not exist.
|
|
748
|
+
ValueError: If path is outside mounted directory, is a directory, or not a valid image.
|
|
749
|
+
|
|
750
|
+
"""
|
|
751
|
+
file_bytes = await self.read_file_bytes(path)
|
|
752
|
+
return ImageContentBlock(data=file_bytes)
|