"""Base types and abstract class for code execution backends."""
import logging
import os
import re
import shlex
import shutil
import tempfile
from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path, PurePath, PurePosixPath
from stat import S_IMODE
from typing import Annotated, Literal
from pydantic import BaseModel, Field
from stirrup.core.models import ImageContentBlock, Tool, ToolProvider, ToolResult, ToolUseCountMetadata
from stirrup.utils.text import truncate_msg
logger = logging.getLogger(__name__)
MAX_LENGTH_SHELL_STDOUT = 20_000
MAX_LENGTH_SHELL_STDERR = 20_000
SHELL_TIMEOUT = 60 * 5
# Reason a command is rejected by the allowlist; each key maps to advice below.
RejectionReason = Literal["empty_command", "no_pattern_match", "shell_syntax", "shell_assignment"]
# Shell operator characters: separators, pipes, redirects, subshell parens,
# backticks, and newline. Appearing unquoted, they mean the command expects a
# shell — but allowlisted commands run without one, so we reject rather than
# pass the operator through as a do-nothing literal argument.
_SHELL_PUNCTUATION = ";<>|&()`\n"
# Advice shown to the model for each rejection reason.
_COMMAND_NOT_ALLOWED_ADVICE: dict[RejectionReason, str] = {
"empty_command": "The command is empty.",
"no_pattern_match": (
"The command does not match any allowlist pattern. Patterns are matched "
"against the parsed command, so quoting does not affect matching."
),
"shell_syntax": (
"Allowlisted commands run without a shell. Shell syntax such as ;, &&, |, "
"redirects, $(...), backticks, or multiple lines is not supported. Run one "
"command with literal arguments, and quote any argument that is itself an "
"operator character (e.g. -exec ... ';')."
),
"shell_assignment": (
"Assignment prefixes such as NAME=value are not supported. Run the command without the leading assignment."
),
}
@dataclass(frozen=True)
class OutputSourceRoot:
"""An absolute execution-environment root accepted when resolving output paths.
Attributes:
path: Absolute POSIX root path (execution environments are Linux).
host_path: Set when this root is a directory on the host filesystem.
Enables canonical (symlink-aware) resolution, e.g. accepting
``/tmp/...`` spellings of a ``/private/tmp/...`` root on macOS.
"""
path: str
host_path: Path | None = None
@classmethod
def for_host(cls, path: Path) -> "OutputSourceRoot":
"""Build a root backed by the host filesystem."""
return cls(path=str(path), host_path=path)
def _safe_output_relative_path(
source_path: str,
*,
source_roots: tuple[OutputSourceRoot, ...] = (),
) -> PurePosixPath:
"""Return a safe POSIX path relative to an output destination.
Relative paths retain their directory structure. Absolute paths are made
relative to the first matching backend root; for host-backed roots a
symlink-equivalent (canonical) spelling of the root is also accepted.
Raises:
ValueError: If the path is empty, contains NUL or traversal, does not
identify a file, or is absolute but outside every declared root.
"""
if not source_path or "\x00" in source_path:
raise ValueError("Output source path is empty or invalid")
source = PurePosixPath(source_path)
if ".." in source.parts:
raise ValueError(f"Output source path contains traversal: {source_path}")
if not source.name:
raise ValueError(f"Output source path does not identify a file: {source_path}")
relative = _relative_to_canonical_host_root(source_path, source_roots)
if relative is None:
relative = source
if source.is_absolute():
for root in (PurePosixPath(item.path) for item in source_roots):
try:
relative = source.relative_to(root)
except ValueError:
continue
break
else:
raise ValueError(f"Output source path is outside known execution roots: {source_path}")
if not relative.name or relative.is_absolute() or ".." in relative.parts:
raise ValueError(f"Output source path cannot be safely preserved: {source_path}")
return relative
def _relative_to_canonical_host_root(
source_path: str,
source_roots: tuple[OutputSourceRoot, ...],
) -> PurePosixPath | None:
"""Preserve a lexical suffix beneath an equivalent canonical host root."""
source = Path(source_path)
if not source.is_absolute():
return None
for root in source_roots:
if root.host_path is None:
continue
canonical_root = root.host_path.resolve(strict=False)
for ancestor in (source, *source.parents):
if ancestor.resolve(strict=False) == canonical_root:
return PurePosixPath(*source.relative_to(ancestor).parts)
return None
def _is_case_sensitive_filesystem(directory: Path) -> bool:
"""Probe whether names differing only by case are distinct in ``directory``."""
directory.mkdir(parents=True, exist_ok=True)
descriptor, probe_name = tempfile.mkstemp(prefix=".stirrup-Case-Probe-", dir=directory)
os.close(descriptor)
probe = Path(probe_name)
alternate = probe.with_name(probe.name.swapcase())
try:
return not alternate.exists() or not os.path.samefile(probe, alternate)
finally:
probe.unlink(missing_ok=True)
def _host_output_destination_identity(destination: Path, output_root: Path) -> str:
"""Validate a host destination and return its filesystem-aware identity."""
resolved_root = output_root.resolve(strict=False)
resolved_destination = destination.resolve(strict=False)
try:
resolved_destination.relative_to(resolved_root)
except ValueError as exc:
raise ValueError(f"Output destination escapes output directory: {destination}") from exc
probe_directory = resolved_destination.parent
while not probe_directory.exists() and probe_directory != resolved_root:
probe_directory = probe_directory.parent
identity = str(resolved_destination)
return identity if _is_case_sensitive_filesystem(probe_directory) else identity.casefold()
def _reserve_output_destination(
destination: Path,
reserved: dict[str, str],
*,
claimant: str,
output_root: Path,
) -> None:
"""Reserve one canonical destination path for ``claimant``."""
identity = _host_output_destination_identity(destination, output_root)
if identity in reserved:
raise ValueError(f"Output destination collision: {destination} (already claimed by {reserved[identity]})")
reserved[identity] = claimant
def _contains_unquoted_shell_operator(cmd: str) -> bool:
"""Return whether cmd contains shell punctuation outside quotes or escapes.
``shlex.split`` validates and parses the command, but its result no longer
records which characters were quoted. This scan keeps only that missing
source-level fact; it does not try to parse or execute shell syntax.
"""
quote: str | None = None
escaped = False
for char in cmd:
if escaped:
escaped = False
elif char == "\\" and quote != "'":
escaped = True
elif char in {"'", '"'}:
if quote is None:
quote = char
elif char == quote:
quote = None
elif quote is None and char in _SHELL_PUNCTUATION:
return True
return False
class CodeExecutionParams(BaseModel):
"""Shell command to execute in the execution environment."""
cmd: Annotated[
str,
Field(
description=(
"Shell command to execute (bash syntax). "
"IMPORTANT: Use only relative paths. Do not use absolute paths "
"(starting with / or ~) or reference directories outside the working directory."
)
),
]
@dataclass
class CommandResult:
"""Raw result from command execution (before formatting)."""
exit_code: int
stdout: str
stderr: str
error_kind: str | None = None # "invalid_argument", "timeout", etc.
advice: str | None = None # Optional advice for error cases
@dataclass
class SavedFile:
"""Information about a file saved from the execution environment."""
source_path: str # Original path in execution environment
output_path: PurePath # Concrete locally; PurePosixPath in another execution environment
size: int
@dataclass(frozen=True)
class _CapturedOutputFile:
"""One host-backed output captured before destination writes begin."""
source_path: str
output_path: Path
snapshot_path: Path
size: int
mode: int
@dataclass
class SaveOutputFilesResult:
"""Result of saving output files from the execution environment."""
saved: list[SavedFile] = field(default_factory=list)
failed: dict[str, str] = field(default_factory=dict) # source_path -> error message
def _atomic_copy(snapshot: Path, destination: Path, mode: int) -> None:
"""Copy a snapshot into place without exposing a partial destination."""
destination.parent.mkdir(parents=True, exist_ok=True)
# The temp name must stay valid even when the destination name is already
# at the filesystem's maximum length.
descriptor, temporary_name = tempfile.mkstemp(prefix=".stirrup-out-", dir=destination.parent)
os.close(descriptor)
temporary_path = Path(temporary_name)
try:
shutil.copyfile(snapshot, temporary_path)
temporary_path.chmod(mode)
temporary_path.replace(destination)
finally:
temporary_path.unlink(missing_ok=True)
def _atomic_write_bytes(content: bytes, destination: Path) -> None:
"""Write bytes atomically to a local output destination."""
destination.parent.mkdir(parents=True, exist_ok=True)
# The temp name must stay valid even when the destination name is already
# at the filesystem's maximum length.
descriptor, temporary_name = tempfile.mkstemp(prefix=".stirrup-out-", dir=destination.parent)
temporary_path = Path(temporary_name)
try:
with os.fdopen(descriptor, "wb") as temporary_file:
temporary_file.write(content)
temporary_path.replace(destination)
finally:
temporary_path.unlink(missing_ok=True)
def _save_host_output_files(
paths: list[str],
output_dir: Path | str,
*,
source_roots: tuple[OutputSourceRoot, ...],
resolve_source: Callable[[str], Path],
) -> SaveOutputFilesResult:
"""Copy host-backed outputs while preserving safe source-relative paths.
Sources are captured before any destination is written so an output
directory may overlap the execution directory without request ordering
changing the copied bytes. Sources remain in the execution environment.
"""
output_root = Path(output_dir)
output_root.mkdir(parents=True, exist_ok=True)
result = SaveOutputFilesResult()
planned_destinations: dict[str, str] = {}
captured_files: list[_CapturedOutputFile] = []
with tempfile.TemporaryDirectory(prefix="stirrup-output-snapshot-") as snapshot_dir_str:
snapshot_dir = Path(snapshot_dir_str)
for index, source_path in enumerate(dict.fromkeys(paths)):
try:
relative_path = _safe_output_relative_path(source_path, source_roots=source_roots)
source_fs_path = resolve_source(source_path)
if not source_fs_path.exists():
raise FileNotFoundError("File does not exist")
if not source_fs_path.is_file():
raise ValueError("Path is not a file")
destination = output_root.joinpath(*relative_path.parts)
_reserve_output_destination(
destination,
planned_destinations,
claimant=source_path,
output_root=output_root,
)
source_stat = source_fs_path.stat()
snapshot_path = snapshot_dir / str(index)
shutil.copyfile(source_fs_path, snapshot_path)
captured_files.append(
_CapturedOutputFile(
source_path=source_path,
output_path=destination,
snapshot_path=snapshot_path,
size=snapshot_path.stat().st_size,
mode=S_IMODE(source_stat.st_mode),
)
)
except (ValueError, FileNotFoundError) as exc:
result.failed[source_path] = str(exc)
logger.warning("Rejected output file %s: %s", source_path, exc)
except Exception as exc:
result.failed[source_path] = str(exc)
logger.exception("Failed to capture output file: %s", source_path)
for captured in captured_files:
try:
_atomic_copy(captured.snapshot_path, captured.output_path, captured.mode)
result.saved.append(SavedFile(captured.source_path, captured.output_path, captured.size))
except Exception as exc:
result.failed[captured.source_path] = str(exc)
logger.exception("Failed to save captured file: %s", captured.source_path)
return result
@dataclass
class UploadedFile:
"""Information about a file uploaded to the execution environment."""
source_path: Path # Original path on local filesystem
dest_path: str # Path in the execution environment
size: int
class ViewImageParams(BaseModel):
"""Parameters for viewing an image from the execution environment."""
path: Annotated[
str,
Field(
description="Path to the image file within the execution environment filesystem (supports .png, .jpg, .jpeg, .gif, .bmp, .tiff, .psd)"
),
]
@dataclass
class UploadFilesResult:
"""Result of uploading files to the execution environment."""
uploaded: list[UploadedFile] = field(default_factory=list)
failed: dict[str, str] = field(default_factory=dict) # source_path -> error message
def format_result(result: CommandResult) -> ToolResult[ToolUseCountMetadata]:
"""Format a CommandResult as XML ToolResult (shared by all backends)."""
if result.error_kind:
# Error case
content = (
f""
f"\n{result.error_kind}"
f"\n{truncate_msg(result.stderr, MAX_LENGTH_SHELL_STDERR)} "
)
if result.advice:
content += f"\n{result.advice}"
content += "\n"
else:
# Success case
content = (
f""
f"\n{result.exit_code}"
f"\n{truncate_msg(result.stdout, MAX_LENGTH_SHELL_STDOUT)}"
f"\n{truncate_msg(result.stderr, MAX_LENGTH_SHELL_STDERR)}"
f"\n"
)
return ToolResult(content=content, metadata=ToolUseCountMetadata())
class CodeExecToolProvider(ToolProvider, ABC):
"""Abstract base class for code execution tool providers.
CodeExecToolProvider is a ToolProvider that manages code execution environments
(sandboxes, containers, local temp directories) and returns a code_exec Tool.
Subclasses must implement:
- __aenter__(): Initialize environment and return the code_exec tool
- __aexit__(): Cleanup the execution environment
- run_command(): Execute a command and return raw result
- read_file_bytes(): Read file content as bytes from the environment
- write_file_bytes(): Write bytes to a file in the environment
Default implementations are provided for:
- save_output_files(): Save files to local dir or another exec env (uses primitives)
- upload_files(): Upload files from local or another exec env (uses primitives)
Overridable output-path hooks (see the extending docs for the contract):
- output_source_roots(): Declare absolute roots for resolving output paths
- resolve_output_source(): Resolve/reject a source before it is read
- output_destination_identity(): Reject escapes and identify cross-env destinations
All providers accept an optional allowlist of command patterns. With an
allowlist, each command is parsed with shlex, matched against the patterns,
and executed without a shell: arguments are passed literally, and shell
syntax such as pipes, redirects, and expansions is rejected. Without an
allowlist, all commands are allowed and run through a shell.
Usage with Agent:
from stirrup.clients.chat_completions_client import ChatCompletionsClient
client = ChatCompletionsClient(model="gpt-5.6-luna", max_tokens=8_192, context_window_tokens=1_000_000)
agent = Agent(
client=client,
name="assistant",
tools=[LocalCodeExecToolProvider(), CALCULATOR_TOOL],
)
"""
def __init__(
self,
*,
allowed_commands: list[str] | None = None,
shell_timeout: int = SHELL_TIMEOUT,
) -> None:
"""Initialize execution environment with optional command allowlist.
Args:
allowed_commands: Optional list of regex patterns, matched from the
start of the parsed command. When set, commands run
without a shell: arguments are literal and shell
syntax is rejected (see ``_prepare_command``). When
None, all commands are allowed and run through a
shell.
shell_timeout: Per-command wall-clock timeout (seconds) applied to
every ``code_exec`` invocation from the LLM. Defaults
to ``SHELL_TIMEOUT``. Callers should set this to match
their application's expected long-running-command
budget rather than rely on the stirrup default.
"""
self._allowed_commands = allowed_commands
self._shell_timeout = shell_timeout
self._compiled_allowed: list[re.Pattern[str]] | None = None
if allowed_commands is not None:
self._compiled_allowed = [re.compile(p) for p in allowed_commands]
@property
def temp_dir(self) -> Path | None:
"""Return the temporary directory for this execution environment, if any."""
return None
def _prepare_command(self, cmd: str) -> tuple[list[str] | None, CommandResult | None]:
"""Check cmd against the allowlist and parse it for execution.
Returns ``(argv, rejection)``, where at most one side is set:
- ``(None, None)``: no allowlist configured. Run ``cmd`` through a
shell as usual.
- ``(None, rejection)``: the command was rejected. Return the rejection.
- ``(argv, None)``: the command is allowed. Execute ``argv`` directly,
with no shell involved.
Patterns are matched against ``shlex.join(argv)`` — the canonical form
of what will actually run — so quoting tricks cannot make a pattern see
a different command than the one executed.
"""
if self._compiled_allowed is None:
return None, None
try:
argv = shlex.split(cmd)
except ValueError:
return None, self._rejection(cmd, "shell_syntax")
if not argv:
return None, self._rejection(cmd, "empty_command")
if _contains_unquoted_shell_operator(cmd):
return None, self._rejection(cmd, "shell_syntax")
if "=" in argv[0]:
return None, self._rejection(cmd, "shell_assignment")
canonical = shlex.join(argv)
if not any(p.match(canonical) for p in self._compiled_allowed):
return None, self._rejection(cmd, "no_pattern_match")
return argv, None
def _rejection(self, cmd: str, reason: RejectionReason) -> CommandResult:
"""Build the CommandResult for an allowlist rejection."""
logger.debug("command rejected (%s): %r", reason, cmd)
return CommandResult(
exit_code=1,
stdout="",
stderr=f"Command not allowed: '{cmd}'",
error_kind="command_not_allowed",
advice=_COMMAND_NOT_ALLOWED_ADVICE[reason],
)
@abstractmethod
async def __aenter__(self) -> Tool[CodeExecutionParams, ToolUseCountMetadata]:
"""Enter async context: set up environment and return code_exec tool."""
...
@abstractmethod
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: object,
) -> None:
"""Exit async context: cleanup the execution environment."""
...
@abstractmethod
async def run_command(self, cmd: str, *, timeout: int | None = None) -> CommandResult:
"""Execute a shell command and return raw CommandResult.
Args:
cmd: Shell command to execute (bash syntax).
timeout: Per-call wall-clock timeout (seconds). If None, falls back
to ``self._shell_timeout`` configured on the provider, so direct
callers and the LLM ``code_exec`` tool share the same default.
"""
...
@abstractmethod
async def read_file_bytes(self, path: str) -> bytes:
"""Read file content as bytes from this execution environment.
Args:
path: File path within this execution environment (relative or absolute
within the env's working directory).
Returns:
File contents as bytes.
Raises:
FileNotFoundError: If file does not exist.
RuntimeError: If execution environment not started.
"""
...
@abstractmethod
async def write_file_bytes(self, path: str, content: bytes) -> None:
"""Write bytes to a file in this execution environment.
Args:
path: Destination path within this execution environment.
content: File contents to write.
Raises:
RuntimeError: If execution environment not started.
"""
...
@abstractmethod
async def file_exists(self, path: str) -> bool:
"""Check if a file exists in this execution environment.
Args:
path: File path within this execution environment (relative or absolute
within the env's working directory).
Returns:
True if the file exists, False otherwise.
Raises:
RuntimeError: If execution environment not started.
"""
...
@abstractmethod
async def is_directory(self, path: str) -> bool:
"""Check if a path is a directory in this execution environment.
Args:
path: Path within this execution environment.
Returns:
True if the path exists and is a directory, False otherwise.
Raises:
RuntimeError: If execution environment not started.
"""
...
@abstractmethod
async def list_files(self, path: str) -> list[str]:
"""List all files recursively in a directory within this execution environment.
Args:
path: Directory path within this execution environment.
Returns:
List of file paths (relative to the given path) for all files in the directory.
Returns an empty list if the path is a file or doesn't exist.
Raises:
RuntimeError: If execution environment not started.
"""
...
def output_source_roots(self) -> tuple[OutputSourceRoot, ...]:
"""Return the absolute execution roots accepted when resolving output paths.
Each root is an absolute path and, for directories on the host
filesystem, carries its host path (see :class:`OutputSourceRoot`).
Backends with absolute sandbox or container paths should override this
so sources beneath a known root can be preserved relative to it.
"""
return ()
async def resolve_output_source(self, source_path: str) -> str:
"""Prepare an output source and return the backend path that is safe to read.
The default implementation returns the path unchanged. Backends whose
files may require preparation before host reads, or whose metadata
exposes symlink targets, should override this to prepare access, resolve
every component, and reject sources whose target escapes an execution
root.
Raises:
ValueError: If the source is invalid or escapes the execution root
(overriding backends only).
"""
return source_path
async def output_destination_identity(self, destination: str, output_root: Path | str) -> str:
"""Validate a cross-environment destination and return its canonical identity."""
normalized_destination = PurePosixPath(destination)
normalized_root = PurePosixPath(str(output_root))
if ".." in normalized_destination.parts:
raise ValueError(f"Output destination escapes output directory: {destination}")
try:
normalized_destination.relative_to(normalized_root)
except ValueError as exc:
raise ValueError(f"Output destination escapes output directory: {destination}") from exc
return normalized_destination.as_posix()
async def save_output_files(
self,
paths: list[str],
output_dir: Path | str,
dest_env: "CodeExecToolProvider | None" = None,
) -> SaveOutputFilesResult:
"""Save files from this execution environment to a destination.
Each source path is preserved relative to ``output_dir``: relative
sources keep their directory structure, and absolute sources are made
relative to a matching root from ``output_source_roots()``. Sources
that are empty, contain traversal, fall outside every declared root,
or fail ``resolve_output_source()`` are rejected, and a second source
that resolves to an already-claimed destination is rejected rather
than overwriting it (first requested output wins). Every rejection is
recorded in ``SaveOutputFilesResult.failed`` as ``source_path ->
reason``. All implementations copy files and leave sources in place.
Host-backed providers preserve file modes.
Args:
paths: List of file paths in this execution environment to save.
output_dir: Directory path to save files to.
dest_env: If provided, output_dir is interpreted as a path within dest_env
(cross-environment transfer). If None, output_dir is a local
filesystem path.
Returns:
SaveOutputFilesResult containing lists of saved files and any failures.
"""
result = SaveOutputFilesResult()
output_dir_str = str(output_dir)
reserved_destinations: dict[str, str] = {}
local_output_root = Path(output_dir)
if dest_env is None:
local_output_root.mkdir(parents=True, exist_ok=True)
for source_path in dict.fromkeys(paths):
try:
relative_path = _safe_output_relative_path(
source_path,
source_roots=self.output_source_roots(),
)
resolved_source_path = await self.resolve_output_source(source_path)
content = await self.read_file_bytes(resolved_source_path)
if dest_env is not None:
# Transfer to another exec env (cross-environment)
destination = PurePosixPath(output_dir_str) / relative_path
identity = await dest_env.output_destination_identity(destination.as_posix(), output_dir)
if identity in reserved_destinations:
raise ValueError(
f"Output destination collision: {destination} "
f"(already claimed by {reserved_destinations[identity]})"
)
reserved_destinations[identity] = source_path
logger.debug(
"CROSS-ENV TRANSFER: %s (%d bytes) -> %s (dest_env: %s)",
source_path,
len(content),
destination,
type(dest_env).__name__,
)
await dest_env.write_file_bytes(destination.as_posix(), content)
result.saved.append(SavedFile(source_path, destination, len(content)))
else:
# Save to local filesystem
destination = local_output_root.joinpath(*relative_path.parts)
_reserve_output_destination(
destination,
reserved_destinations,
claimant=source_path,
output_root=local_output_root,
)
logger.debug(
"SAVE TO LOCAL: %s (%d bytes) -> %s",
source_path,
len(content),
destination,
)
_atomic_write_bytes(content, destination)
result.saved.append(SavedFile(source_path, destination, len(content)))
except (ValueError, FileNotFoundError) as e:
logger.warning("TRANSFER FAILED: %s -> %s: %s", source_path, output_dir_str, e)
result.failed[source_path] = str(e)
except Exception as e:
logger.exception("TRANSFER FAILED: %s -> %s", source_path, output_dir_str)
result.failed[source_path] = str(e)
return result
async def upload_files(
self,
*paths: Path | str,
source_env: "CodeExecToolProvider | None" = None,
dest_dir: str | None = None,
) -> UploadFilesResult:
"""Upload files to this execution environment.
Args:
*paths: File or directory paths to upload. If source_env is None, these
are local filesystem paths. If source_env is provided, these are
paths within source_env (cross-environment transfer).
source_env: If provided, paths are within source_env. If None, paths are
local filesystem paths.
dest_dir: Destination directory in this environment.
If None, uses the environment's working directory.
Returns:
UploadFilesResult containing lists of uploaded files and any failures.
Raises:
RuntimeError: If execution environment not started.
"""
result = UploadFilesResult()
dest_dir_str = dest_dir or ""
for path in paths:
path_str = str(path)
try:
if source_env:
# Cross-environment transfer: read from source_env
# Check if it's a directory first
if await source_env.is_directory(path_str):
# Handle directory recursively
# Preserve directory name when dest_dir not specified
dir_name = Path(path_str).name
files = await source_env.list_files(path_str)
for rel_file_path in files:
src_file_path = f"{path_str}/{rel_file_path}"
# If dest_dir specified, put files directly there
# Otherwise, preserve the source directory name
if dest_dir_str:
dest_path = f"{dest_dir_str}/{rel_file_path}"
else:
dest_path = f"{dir_name}/{rel_file_path}"
content = await source_env.read_file_bytes(src_file_path)
logger.debug(
"UPLOAD CROSS-ENV (dir): %s (%d bytes) from %s -> %s",
src_file_path,
len(content),
type(source_env).__name__,
dest_path,
)
await self.write_file_bytes(dest_path, content)
result.uploaded.append(UploadedFile(Path(src_file_path), dest_path, len(content)))
else:
# Single file transfer
content = await source_env.read_file_bytes(path_str)
filename = Path(path_str).name
dest_path = f"{dest_dir_str}/{filename}" if dest_dir_str else filename
logger.debug(
"UPLOAD CROSS-ENV: %s (%d bytes) from %s -> %s",
path_str,
len(content),
type(source_env).__name__,
dest_path,
)
await self.write_file_bytes(dest_path, content)
result.uploaded.append(UploadedFile(Path(path_str), dest_path, len(content)))
else:
# Local filesystem upload - must be handled by subclass
# This is a fallback that reads from local fs and writes to env
local_path = Path(path)
if local_path.is_dir():
# Handle directory recursively
for file_path in local_path.rglob("*"):
if file_path.is_file():
rel_path = file_path.relative_to(local_path)
dest_path = f"{dest_dir_str}/{rel_path}" if dest_dir_str else str(rel_path)
content = file_path.read_bytes()
logger.debug(
"UPLOAD FROM LOCAL: %s (%d bytes) -> %s",
file_path,
len(content),
dest_path,
)
await self.write_file_bytes(dest_path, content)
result.uploaded.append(UploadedFile(file_path, dest_path, len(content)))
else:
filename = local_path.name
dest_path = f"{dest_dir_str}/{filename}" if dest_dir_str else filename
content = local_path.read_bytes()
logger.debug(
"UPLOAD FROM LOCAL: %s (%d bytes) -> %s",
local_path,
len(content),
dest_path,
)
await self.write_file_bytes(dest_path, content)
result.uploaded.append(UploadedFile(local_path, dest_path, len(content)))
except Exception as e:
logger.debug("UPLOAD FAILED: %s -> %s: %s", path_str, dest_dir_str, e)
result.failed[path_str] = str(e)
return result
def get_code_exec_tool(
self,
*,
name: str = "code_exec",
description: str | None = None,
) -> Tool[CodeExecutionParams, ToolUseCountMetadata]:
"""Create a code execution tool for this environment.
Args:
name: Tool name
description: Tool description
Returns:
Tool[CodeExecutionParams] that executes commands in this environment
"""
env = self
async def executor(params: CodeExecutionParams) -> ToolResult[ToolUseCountMetadata]:
# timeout=None defers to env._shell_timeout, set in CodeExecToolProvider.__init__.
result = await env.run_command(params.cmd)
return format_result(result)
return Tool[CodeExecutionParams, ToolUseCountMetadata](
name=name,
description=description
or "Execute a shell command in the execution environment. Returns exit code, stdout, and stderr as XML.",
parameters=CodeExecutionParams,
executor=executor,
)
def get_view_image_tool(
self,
*,
name: str = "view_image",
description: str | None = None,
) -> Tool[ViewImageParams, ToolUseCountMetadata]:
"""Create a view_image tool for this environment.
Args:
name: Tool name
description: Tool description
Returns:
Tool[ViewImageParams, ToolUseCountMetadata] that views images in this environment
"""
env = self
async def executor(params: ViewImageParams) -> ToolResult[ToolUseCountMetadata]:
try:
image = await env.view_image(params.path)
return ToolResult(
content=["Viewing image at path: " + params.path, image],
metadata=ToolUseCountMetadata(),
)
except FileNotFoundError:
return ToolResult(
content=f"Image `{params.path}` not found.",
success=False,
metadata=ToolUseCountMetadata(),
)
except ValueError as e:
return ToolResult(
content=str(e),
success=False,
metadata=ToolUseCountMetadata(),
)
return Tool[ViewImageParams, ToolUseCountMetadata](
name=name,
description=description or "View an image file from the execution environment's filesystem.",
parameters=ViewImageParams,
executor=executor,
)
@abstractmethod
async def view_image(self, path: str) -> ImageContentBlock:
"""Read and return an image file from the execution environment.
Args:
path: Path to image file in the execution environment (relative or absolute).
Returns:
ImageContentBlock containing the image data.
Raises:
RuntimeError: If execution environment not started.
FileNotFoundError: If file does not exist.
ValueError: If path is outside the execution environment, is a directory,
or the file is not a valid image.
"""
...