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.
@@ -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)