Files
openai--openai-agents-python/src/agents/extensions/sandbox/daytona/sandbox.py
T
2026-07-13 12:39:17 +08:00

1371 lines
50 KiB
Python

"""
Daytona sandbox (https://daytona.io) implementation.
This module provides a Daytona-backed sandbox client/session implementation backed by
`daytona.Sandbox` via the AsyncDaytona client.
The `daytona` dependency is optional, so package-level exports should guard imports of this
module. Within this module, Daytona SDK imports are lazy so users without the extra can still
import the package.
"""
from __future__ import annotations
import asyncio
import io
import logging
import math
import shlex
import time
import uuid
from collections import deque
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal, cast
from urllib.parse import urlsplit
from pydantic import BaseModel, Field
from ....sandbox.entries import Mount
from ....sandbox.errors import (
ExecTimeoutError,
ExecTransportError,
ExposedPortUnavailableError,
InvalidManifestPathError as InvalidManifestPathError,
WorkspaceArchiveReadError,
WorkspaceArchiveWriteError,
WorkspaceReadNotFoundError,
WorkspaceStartError,
WorkspaceWriteTypeError,
)
from ....sandbox.manifest import Manifest
from ....sandbox.session import SandboxSession, SandboxSessionState
from ....sandbox.session.base_sandbox_session import BaseSandboxSession
from ....sandbox.session.dependencies import Dependencies
from ....sandbox.session.manager import Instrumentation
from ....sandbox.session.pty_output import collect_pty_output
from ....sandbox.session.pty_types import (
PTY_PROCESSES_MAX,
PTY_PROCESSES_WARNING,
PtyExecUpdate,
allocate_pty_process_id,
clamp_pty_yield_time_ms,
process_id_to_prune_from_meta,
resolve_pty_write_yield_time_ms,
)
from ....sandbox.session.runtime_helpers import RESOLVE_WORKSPACE_PATH_HELPER, RuntimeHelperScript
from ....sandbox.session.sandbox_client import BaseSandboxClient, BaseSandboxClientOptions
from ....sandbox.session.tar_workspace import shell_tar_exclude_args
from ....sandbox.snapshot import SnapshotBase, SnapshotSpec, resolve_snapshot
from ....sandbox.types import ExecResult, ExposedPortEndpoint, User
from ....sandbox.util.retry import (
TRANSIENT_HTTP_STATUS_CODES,
exception_chain_contains_type,
exception_chain_has_status_code,
iter_exception_chain,
retry_async,
)
from ....sandbox.util.tar_utils import UnsafeTarMemberError, validate_tar_bytes
from ....sandbox.workspace_paths import (
coerce_posix_path,
posix_path_as_path,
posix_path_for_error,
sandbox_path_str,
)
DEFAULT_DAYTONA_WORKSPACE_ROOT = "/home/daytona/workspace"
logger = logging.getLogger(__name__)
# Daytona documents SDK error subclasses plus `status_code` and `error_code` fields at:
# https://www.daytona.io/docs/en/python-sdk/common/errors/
_DAYTONA_HTTP_STATUS_RETRYABLE: dict[int, bool] = {
400: False,
401: False,
403: False,
404: False,
409: False,
429: True,
500: True,
502: True,
503: True,
504: True,
}
def _daytona_provider_error_detail(error: BaseException) -> str | None:
message = str(error)
status = getattr(error, "status_code", None) or getattr(error, "status", None)
if isinstance(status, int):
if message:
return f"HTTP {status}: {message}"
return f"HTTP {status}"
if message:
return f"{type(error).__name__}: {message}"
return type(error).__name__
def _daytona_provider_retryability(error: BaseException) -> tuple[bool | None, str | None]:
non_retryable_types = _daytona_non_retryable_error_types()
retryable_types = _daytona_retryable_error_types()
for candidate in iter_exception_chain(error):
provider_error_code = getattr(candidate, "error_code", None)
reason = str(provider_error_code) if isinstance(provider_error_code, str) else None
if non_retryable_types and isinstance(candidate, non_retryable_types):
return False, reason
if retryable_types and isinstance(candidate, retryable_types):
return True, reason
status = getattr(candidate, "status_code", None) or getattr(candidate, "status", None)
if isinstance(status, int):
retryable = _DAYTONA_HTTP_STATUS_RETRYABLE.get(status)
if retryable is not None:
return retryable, reason or f"http_{status}"
message = str(candidate).lower()
if "is the sandbox started" in message or "no ip address found" in message:
return False, "sandbox_not_running"
if exception_chain_contains_type(error, _retryable_persist_workspace_error_types()):
return True, "provider_timeout"
return None, None
def _daytona_exec_transport_error(
*,
command: tuple[str | Path, ...],
cause: BaseException,
) -> ExecTransportError:
detail = _daytona_provider_error_detail(cause)
context: dict[str, object] = {"backend": "daytona"}
retryable, reason = _daytona_provider_retryability(cause)
if reason is not None:
context["reason"] = reason
if detail:
context["provider_error"] = detail
provider_error_code = getattr(cause, "error_code", None)
if isinstance(provider_error_code, str) and provider_error_code:
context["provider_error_code"] = provider_error_code
status = getattr(cause, "status_code", None) or getattr(cause, "status", None)
if isinstance(status, int):
context["http_status"] = status
message = "Daytona exec failed"
if detail:
message = f"{message}: {detail}"
return ExecTransportError(
command=command,
context=context,
cause=cause,
message=message,
retryable=retryable,
)
def _import_daytona_sdk() -> tuple[Any, Any, Any, Any]:
"""Lazily import Daytona SDK classes, raising a clear error if missing."""
try:
from daytona import (
AsyncDaytona,
CreateSandboxFromImageParams,
CreateSandboxFromSnapshotParams,
DaytonaConfig,
)
return (
AsyncDaytona,
DaytonaConfig,
CreateSandboxFromSnapshotParams,
CreateSandboxFromImageParams,
)
except ImportError as e:
raise ImportError(
"DaytonaSandboxClient requires the optional `daytona` dependency.\n"
"Install the Daytona extra before using this sandbox backend."
) from e
def _import_sandbox_state() -> Any:
"""Lazily import SandboxState enum from Daytona SDK, or None if unavailable."""
try:
from daytona import SandboxState
return SandboxState
except ImportError:
return None
def _import_sdk_resources() -> Any:
"""Lazily import Resources from Daytona SDK."""
try:
from daytona import Resources
return Resources
except ImportError as e:
raise ImportError(
"DaytonaSandboxClient requires the optional `daytona` dependency.\n"
"Install the Daytona extra before using this sandbox backend."
) from e
def _import_pty_size() -> Any:
"""Lazily import PtySize from Daytona SDK."""
try:
from daytona.common.pty import PtySize
return PtySize
except ImportError as e:
raise ImportError(
"DaytonaSandboxClient requires the optional `daytona` dependency.\n"
"Install the Daytona extra before using this sandbox backend."
) from e
def _import_session_execute_request() -> Any:
"""Lazily import SessionExecuteRequest from Daytona SDK."""
try:
from daytona import SessionExecuteRequest
return SessionExecuteRequest
except ImportError as e:
raise ImportError(
"DaytonaSandboxClient requires the optional `daytona` dependency.\n"
"Install the Daytona extra before using this sandbox backend."
) from e
def _daytona_exception_types(*names: str) -> tuple[type[BaseException], ...]:
"""Best-effort import of Daytona exception classes by name."""
try:
daytona_module = __import__("daytona")
except Exception:
return ()
exceptions: list[type[BaseException]] = []
for name in names:
value = getattr(daytona_module, name, None)
if isinstance(value, type) and issubclass(value, BaseException):
exceptions.append(value)
return tuple(exceptions)
def _daytona_retryable_error_types() -> tuple[type[BaseException], ...]:
return _daytona_exception_types(
"DaytonaRateLimitError",
"DaytonaTimeoutError",
"DaytonaConnectionError",
)
def _daytona_timeout_error_types() -> tuple[type[BaseException], ...]:
return _daytona_exception_types("DaytonaTimeoutError")
def _daytona_non_retryable_error_types() -> tuple[type[BaseException], ...]:
return _daytona_exception_types(
"DaytonaNotFoundError",
"DaytonaAuthenticationError",
"DaytonaAuthorizationError",
"DaytonaValidationError",
"DaytonaConflictError",
)
def _daytona_not_found_error_types() -> tuple[type[BaseException], ...]:
return _daytona_exception_types("DaytonaNotFoundError")
def _retryable_persist_workspace_error_types() -> tuple[type[BaseException], ...]:
return (asyncio.TimeoutError, *_daytona_timeout_error_types())
class DaytonaSandboxResources(BaseModel):
"""Resource configuration for a Daytona sandbox."""
model_config = {"frozen": True}
cpu: int | None = None
memory: int | None = None
disk: int | None = None
class DaytonaSandboxTimeouts(BaseModel):
"""Timeout configuration for Daytona sandbox operations."""
exec_timeout_unbounded_s: int = Field(default=24 * 60 * 60, ge=1)
keepalive_s: int = Field(default=10, ge=1)
cleanup_s: int = Field(default=30, ge=1)
fast_op_s: int = Field(default=30, ge=1)
file_upload_s: int = Field(default=1800, ge=1)
file_download_s: int = Field(default=1800, ge=1)
workspace_tar_s: int = Field(default=300, ge=1)
class DaytonaSandboxClientOptions(BaseSandboxClientOptions):
"""Client options for the Daytona sandbox."""
type: Literal["daytona"] = "daytona"
sandbox_snapshot_name: str | None = None
image: str | None = None
resources: DaytonaSandboxResources | None = None
env_vars: dict[str, str] | None = None
pause_on_exit: bool = False
create_timeout: int = 60
start_timeout: int = 60
name: str | None = None
auto_stop_interval: int = 0
timeouts: DaytonaSandboxTimeouts | dict[str, object] | None = None
exposed_ports: tuple[int, ...] = ()
# This TTL applies to new connection setup only: Daytona checks signed preview URL expiry during
# the initial HTTP request / websocket upgrade handshake. In live testing, an already-open
# websocket stayed connected after the URL expired, but any reconnect or new handshake needed a
# freshly resolved URL.
exposed_port_url_ttl_s: int = 3600
def __init__(
self,
sandbox_snapshot_name: str | None = None,
image: str | None = None,
resources: DaytonaSandboxResources | None = None,
env_vars: dict[str, str] | None = None,
pause_on_exit: bool = False,
create_timeout: int = 60,
start_timeout: int = 60,
name: str | None = None,
auto_stop_interval: int = 0,
timeouts: DaytonaSandboxTimeouts | dict[str, object] | None = None,
exposed_ports: tuple[int, ...] = (),
exposed_port_url_ttl_s: int = 3600,
*,
type: Literal["daytona"] = "daytona",
) -> None:
super().__init__(
type=type,
sandbox_snapshot_name=sandbox_snapshot_name,
image=image,
resources=resources,
env_vars=env_vars,
pause_on_exit=pause_on_exit,
create_timeout=create_timeout,
start_timeout=start_timeout,
name=name,
auto_stop_interval=auto_stop_interval,
timeouts=timeouts,
exposed_ports=exposed_ports,
exposed_port_url_ttl_s=exposed_port_url_ttl_s,
)
class DaytonaSandboxSessionState(SandboxSessionState):
"""Serializable state for a Daytona-backed session."""
type: Literal["daytona"] = "daytona"
sandbox_id: str
sandbox_snapshot_name: str | None = None
image: str | None = None
base_env_vars: dict[str, str] = Field(default_factory=dict)
pause_on_exit: bool = False
create_timeout: int = 60
start_timeout: int = 60
name: str | None = None
resources: DaytonaSandboxResources | None = None
auto_stop_interval: int = 0
timeouts: DaytonaSandboxTimeouts = Field(default_factory=DaytonaSandboxTimeouts)
exposed_port_url_ttl_s: int = 3600
@dataclass
class _DaytonaPtySessionEntry:
daytona_session_id: str
pty_handle: Any
tty: bool = True
cmd_id: str | None = None
output_chunks: deque[bytes] = field(default_factory=deque)
output_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
output_notify: asyncio.Event = field(default_factory=asyncio.Event)
last_used: float = field(default_factory=time.monotonic)
done: bool = False
exit_code: int | None = None
worker_task: asyncio.Task[None] | None = None
class DaytonaSandboxSession(BaseSandboxSession):
"""Daytona-backed sandbox session implementation."""
state: DaytonaSandboxSessionState
_sandbox: Any
_pty_lock: asyncio.Lock
_pty_sessions: dict[int, _DaytonaPtySessionEntry]
_reserved_pty_process_ids: set[int]
def __init__(self, *, state: DaytonaSandboxSessionState, sandbox: Any) -> None:
self.state = state
self._sandbox = sandbox
self._pty_lock = asyncio.Lock()
self._pty_sessions = {}
self._reserved_pty_process_ids = set()
@classmethod
def from_state(
cls,
state: DaytonaSandboxSessionState,
*,
sandbox: Any,
) -> DaytonaSandboxSession:
return cls(state=state, sandbox=sandbox)
@property
def sandbox_id(self) -> str:
return self.state.sandbox_id
async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
try:
preview = await self._sandbox.create_signed_preview_url(
port,
expires_in_seconds=self.state.exposed_port_url_ttl_s,
)
except Exception as e:
raise ExposedPortUnavailableError(
port=port,
exposed_ports=self.state.exposed_ports,
reason="backend_unavailable",
context={"backend": "daytona", "detail": "create_signed_preview_url_failed"},
cause=e,
) from e
url = getattr(preview, "url", None)
if not isinstance(url, str) or not url:
raise ExposedPortUnavailableError(
port=port,
exposed_ports=self.state.exposed_ports,
reason="backend_unavailable",
context={"backend": "daytona", "detail": "invalid_preview_url", "url": url},
)
try:
split = urlsplit(url)
host = split.hostname
if host is None:
raise ValueError("missing hostname")
port_value = split.port or (443 if split.scheme == "https" else 80)
return ExposedPortEndpoint(host=host, port=port_value, tls=split.scheme == "https")
except Exception as e:
raise ExposedPortUnavailableError(
port=port,
exposed_ports=self.state.exposed_ports,
reason="backend_unavailable",
context={"backend": "daytona", "detail": "invalid_preview_url", "url": url},
cause=e,
) from e
async def _shutdown_backend(self) -> None:
try:
if self.state.pause_on_exit:
await self._sandbox.stop()
else:
await self._sandbox.delete()
except Exception:
pass
async def _validate_path_access(self, path: Path | str, *, for_write: bool = False) -> Path:
return await self._validate_remote_path_access(path, for_write=for_write)
def _runtime_helpers(self) -> tuple[RuntimeHelperScript, ...]:
return (RESOLVE_WORKSPACE_PATH_HELPER,)
async def _prepare_workspace_root(self) -> None:
"""Create the workspace root before SDK exec calls use it as cwd."""
root = sandbox_path_str(self.state.manifest.root)
error_root = posix_path_for_error(root)
try:
envs = await self._resolved_envs()
result = await self._sandbox.process.exec(
f"mkdir -p -- {shlex.quote(root)}",
env=envs or None,
timeout=self.state.timeouts.fast_op_s,
)
except Exception as e:
detail = _daytona_provider_error_detail(e)
message = "failed to start session"
if detail:
message = f"{message}: Daytona workspace root setup failed: {detail}"
raise WorkspaceStartError(
path=error_root,
context={"backend": "daytona", "reason": "workspace_root_setup_failed"},
cause=e,
message=message,
) from e
exit_code = int(getattr(result, "exit_code", 0) or 0)
if exit_code != 0:
output = str(getattr(result, "result", "") or "")
message = (
f"failed to start session: Daytona workspace root setup exited with {exit_code}"
)
if output:
message = f"{message}: {output}"
raise WorkspaceStartError(
path=error_root,
context={
"backend": "daytona",
"reason": "workspace_root_nonzero_exit",
"exit_code": exit_code,
"output": output,
},
message=message,
)
async def _prepare_backend_workspace(self) -> None:
await self._prepare_workspace_root()
async def mkdir(
self,
path: Path | str,
*,
parents: bool = False,
user: str | User | None = None,
) -> None:
if user is not None:
path = await self._check_mkdir_with_exec(path, parents=parents, user=user)
else:
path = await self._validate_path_access(path, for_write=True)
if path == Path("/"):
return
try:
await self._sandbox.fs.create_folder(sandbox_path_str(path), "755")
except Exception as e:
raise WorkspaceArchiveWriteError(
path=path,
context={"reason": "mkdir_failed"},
cause=e,
) from e
async def _resolved_envs(self) -> dict[str, str]:
manifest_envs = await self.state.manifest.environment.resolve()
return {**self.state.base_env_vars, **manifest_envs}
def _coerce_exec_timeout(self, timeout_s: float | None) -> float:
if timeout_s is None:
return float(self.state.timeouts.exec_timeout_unbounded_s)
if timeout_s <= 0:
return 0.001
return float(timeout_s)
async def _exec_internal(
self,
*command: str | Path,
timeout: float | None = None,
) -> ExecResult:
cmd_str = shlex.join(str(c) for c in command)
envs = await self._resolved_envs()
cwd = sandbox_path_str(self.state.manifest.root)
env_args = (
" ".join(shlex.quote(f"{key}={value}") for key, value in envs.items()) if envs else ""
)
env_wrapper = f"env -- {env_args} " if env_args else ""
session_cmd = f"cd {shlex.quote(cwd)} && {env_wrapper}{cmd_str}"
daytona_session_id = f"sandbox-{uuid.uuid4().hex[:12]}"
caller_timeout = self._coerce_exec_timeout(timeout)
deadline = time.monotonic() + caller_timeout
SessionExecuteRequest = _import_session_execute_request()
timeout_error_types = _daytona_timeout_error_types()
def _remaining_timeout() -> float:
return max(0.0, deadline - time.monotonic())
try:
await asyncio.wait_for(
self._sandbox.process.create_session(daytona_session_id),
timeout=_remaining_timeout(),
)
command_timeout = _remaining_timeout()
sdk_timeout = max(1, math.ceil(command_timeout + 1.0))
result = await asyncio.wait_for(
self._sandbox.process.execute_session_command(
daytona_session_id,
SessionExecuteRequest(command=session_cmd, run_async=False),
timeout=sdk_timeout,
),
timeout=caller_timeout,
)
exit_code = int(result.exit_code or 0)
stdout = getattr(result, "stdout", None)
stderr = getattr(result, "stderr", None)
if stdout is None and stderr is None:
output = getattr(result, "output", "") or ""
if exit_code == 0:
stdout = output
stderr = ""
else:
stdout = ""
stderr = output
return ExecResult(
stdout=(stdout or "").encode("utf-8", errors="replace"),
stderr=(stderr or "").encode("utf-8", errors="replace"),
exit_code=exit_code,
)
except asyncio.TimeoutError as e:
raise ExecTimeoutError(command=command, timeout_s=timeout, cause=e) from e
except Exception as e:
if timeout_error_types and isinstance(e, timeout_error_types):
raise ExecTimeoutError(command=command, timeout_s=timeout, cause=e) from e
raise _daytona_exec_transport_error(command=command, cause=e) from e
finally:
try:
await asyncio.wait_for(
self._sandbox.process.delete_session(daytona_session_id),
timeout=self.state.timeouts.cleanup_s,
)
except Exception:
pass
def supports_pty(self) -> bool:
return True
async def pty_exec_start(
self,
*command: str | Path,
timeout: float | None = None,
shell: bool | list[str] = True,
user: str | User | None = None,
tty: bool = False,
yield_time_s: float | None = None,
max_output_tokens: int | None = None,
) -> PtyExecUpdate:
PtySize = _import_pty_size()
sanitized = self._prepare_exec_command(*command, shell=shell, user=user)
cmd_str = shlex.join(str(part) for part in sanitized)
envs = await self._resolved_envs()
cwd = sandbox_path_str(self.state.manifest.root)
exec_timeout = self._coerce_exec_timeout(timeout)
timeout_error_types = _daytona_timeout_error_types()
daytona_session_id = f"sandbox-{uuid.uuid4().hex[:12]}"
entry = _DaytonaPtySessionEntry(
daytona_session_id=daytona_session_id,
pty_handle=None,
tty=tty,
)
async def _on_data(chunk: bytes | str) -> None:
raw = (
chunk.encode("utf-8", errors="replace") if isinstance(chunk, str) else bytes(chunk)
)
async with entry.output_lock:
entry.output_chunks.append(raw)
entry.output_notify.set()
pruned: _DaytonaPtySessionEntry | None = None
registered = False
try:
if tty:
pty_handle = await asyncio.wait_for(
self._sandbox.process.create_pty_session(
id=daytona_session_id,
on_data=_on_data,
cwd=cwd,
envs=envs or None,
pty_size=PtySize(cols=80, rows=24),
),
timeout=exec_timeout,
)
entry.pty_handle = pty_handle
entry.worker_task = asyncio.create_task(self._run_pty_waiter(entry))
await asyncio.wait_for(pty_handle.wait_for_connection(), timeout=exec_timeout)
await asyncio.wait_for(
pty_handle.send_input(cmd_str + "\n"),
timeout=self.state.timeouts.fast_op_s,
)
else:
SessionExecuteRequest = _import_session_execute_request()
env_args = (
" ".join(shlex.quote(f"{key}={value}") for key, value in envs.items())
if envs
else ""
)
env_wrapper = f"env -- {env_args} " if env_args else ""
session_cmd = f"cd {shlex.quote(cwd)} && {env_wrapper}{cmd_str}"
await asyncio.wait_for(
self._sandbox.process.create_session(daytona_session_id),
timeout=exec_timeout,
)
resp = await asyncio.wait_for(
self._sandbox.process.execute_session_command(
daytona_session_id,
SessionExecuteRequest(command=session_cmd, run_async=True),
),
timeout=exec_timeout,
)
entry.cmd_id = resp.cmd_id
entry.worker_task = asyncio.create_task(
self._run_session_reader(
entry,
daytona_session_id,
resp.cmd_id,
_on_data,
)
)
async with self._pty_lock:
process_id = allocate_pty_process_id(self._reserved_pty_process_ids)
self._reserved_pty_process_ids.add(process_id)
pruned = self._prune_pty_sessions_if_needed()
self._pty_sessions[process_id] = entry
process_count = len(self._pty_sessions)
registered = True
except asyncio.TimeoutError as e:
if not registered:
cleanup_task = asyncio.ensure_future(self._terminate_pty_entry(entry))
try:
await asyncio.shield(cleanup_task)
except BaseException:
await asyncio.shield(cleanup_task)
raise ExecTimeoutError(command=command, timeout_s=timeout, cause=e) from e
except Exception as e:
if not registered:
cleanup_task = asyncio.ensure_future(self._terminate_pty_entry(entry))
try:
await asyncio.shield(cleanup_task)
except BaseException:
await asyncio.shield(cleanup_task)
if timeout_error_types and isinstance(e, timeout_error_types):
raise ExecTimeoutError(command=command, timeout_s=timeout, cause=e) from e
raise _daytona_exec_transport_error(command=command, cause=e) from e
except BaseException:
if not registered:
cleanup_task = asyncio.ensure_future(self._terminate_pty_entry(entry))
try:
await asyncio.shield(cleanup_task)
except BaseException:
await asyncio.shield(cleanup_task)
raise
if pruned is not None:
await self._terminate_pty_entry(pruned)
if process_count >= PTY_PROCESSES_WARNING:
logger.warning(
"PTY process count reached warning threshold: %s active sessions",
process_count,
)
yield_time_ms = 10_000 if yield_time_s is None else int(yield_time_s * 1000)
output, original_token_count = await self._collect_pty_output(
entry=entry,
yield_time_ms=clamp_pty_yield_time_ms(yield_time_ms),
max_output_tokens=max_output_tokens,
)
return await self._finalize_pty_update(
process_id=process_id,
entry=entry,
output=output,
original_token_count=original_token_count,
)
async def _run_pty_waiter(self, entry: _DaytonaPtySessionEntry) -> None:
try:
await entry.pty_handle.wait()
ec = getattr(entry.pty_handle, "exit_code", None)
if ec is not None:
entry.exit_code = int(ec)
except Exception:
pass
finally:
entry.done = True
entry.output_notify.set()
async def _run_session_reader(
self,
entry: _DaytonaPtySessionEntry,
session_id: str,
cmd_id: str,
on_data: Any,
) -> None:
logs_failed = False
try:
await self._sandbox.process.get_session_command_logs_async(
session_id,
cmd_id,
on_data,
on_data,
)
except Exception:
logs_failed = True
finally:
try:
cmd = await self._sandbox.process.get_session_command(session_id, cmd_id)
if cmd.exit_code is not None:
entry.exit_code = int(cmd.exit_code)
entry.done = True
except Exception:
pass
if not logs_failed:
entry.done = True
entry.output_notify.set()
async def pty_write_stdin(
self,
*,
session_id: int,
chars: str,
yield_time_s: float | None = None,
max_output_tokens: int | None = None,
) -> PtyExecUpdate:
async with self._pty_lock:
entry = self._resolve_pty_session_entry(
pty_processes=self._pty_sessions,
session_id=session_id,
)
if chars:
if not entry.tty:
raise RuntimeError("stdin is not available for this process")
await asyncio.wait_for(
entry.pty_handle.send_input(chars),
timeout=self.state.timeouts.fast_op_s,
)
await asyncio.sleep(0.1)
yield_time_ms = 250 if yield_time_s is None else int(yield_time_s * 1000)
output, original_token_count = await self._collect_pty_output(
entry=entry,
yield_time_ms=resolve_pty_write_yield_time_ms(
yield_time_ms=yield_time_ms, input_empty=chars == ""
),
max_output_tokens=max_output_tokens,
)
entry.last_used = time.monotonic()
return await self._finalize_pty_update(
process_id=session_id,
entry=entry,
output=output,
original_token_count=original_token_count,
)
async def _finalize_pty_update(
self,
*,
process_id: int,
entry: _DaytonaPtySessionEntry,
output: bytes,
original_token_count: int | None,
) -> PtyExecUpdate:
exit_code = entry.exit_code if entry.done else None
live_process_id: int | None = process_id
if entry.done:
async with self._pty_lock:
removed = self._pty_sessions.pop(process_id, None)
self._reserved_pty_process_ids.discard(process_id)
if removed is not None:
await self._terminate_pty_entry(removed)
live_process_id = None
return PtyExecUpdate(
process_id=live_process_id,
output=output,
exit_code=exit_code,
original_token_count=original_token_count,
)
async def pty_terminate_all(self) -> None:
async with self._pty_lock:
entries = list(self._pty_sessions.values())
self._pty_sessions.clear()
self._reserved_pty_process_ids.clear()
for entry in entries:
await self._terminate_pty_entry(entry)
async def _collect_pty_output(
self,
*,
entry: _DaytonaPtySessionEntry,
yield_time_ms: int,
max_output_tokens: int | None,
) -> tuple[bytes, int | None]:
return await collect_pty_output(
output_chunks=entry.output_chunks,
output_lock=entry.output_lock,
output_notify=entry.output_notify,
is_done=lambda: entry.done,
yield_time_ms=yield_time_ms,
max_output_tokens=max_output_tokens,
)
def _prune_pty_sessions_if_needed(self) -> _DaytonaPtySessionEntry | None:
if len(self._pty_sessions) < PTY_PROCESSES_MAX:
return None
meta: list[tuple[int, float, bool]] = [
(pid, entry.last_used, entry.done) for pid, entry in self._pty_sessions.items()
]
pid = process_id_to_prune_from_meta(meta)
if pid is None:
return None
self._reserved_pty_process_ids.discard(pid)
return self._pty_sessions.pop(pid, None)
async def _terminate_pty_entry(self, entry: _DaytonaPtySessionEntry) -> None:
try:
if entry.tty:
await self._sandbox.process.kill_pty_session(entry.daytona_session_id)
else:
await self._sandbox.process.delete_session(entry.daytona_session_id)
except Exception:
pass
finally:
worker_task = entry.worker_task
entry.worker_task = None
if worker_task is not None and worker_task is not asyncio.current_task():
if not worker_task.done():
worker_task.cancel()
try:
await asyncio.wait_for(
asyncio.gather(worker_task, return_exceptions=True),
timeout=self.state.timeouts.cleanup_s,
)
except asyncio.TimeoutError:
pass
async def read(self, path: Path | str, *, user: str | User | None = None) -> io.IOBase:
error_path = posix_path_as_path(coerce_posix_path(path))
if user is not None:
workspace_path = await self._check_read_with_exec(path, user=user)
else:
workspace_path = await self._validate_path_access(path)
not_found_error_types = _daytona_not_found_error_types()
try:
data: bytes = await self._sandbox.fs.download_file(
sandbox_path_str(workspace_path),
self.state.timeouts.file_download_s,
)
return io.BytesIO(data)
except Exception as e:
if not_found_error_types and isinstance(e, not_found_error_types):
raise WorkspaceReadNotFoundError(path=error_path, cause=e) from e
raise WorkspaceArchiveReadError(path=error_path, cause=e) from e
async def write(
self,
path: Path | str,
data: io.IOBase,
*,
user: str | User | None = None,
) -> None:
error_path = posix_path_as_path(coerce_posix_path(path))
if user is not None:
await self._check_write_with_exec(path, user=user)
payload = data.read()
if isinstance(payload, str):
payload = payload.encode("utf-8")
if not isinstance(payload, bytes | bytearray):
raise WorkspaceWriteTypeError(path=error_path, actual_type=type(payload).__name__)
workspace_path = await self._validate_path_access(path, for_write=True)
try:
await self._sandbox.fs.upload_file(
bytes(payload),
sandbox_path_str(workspace_path),
timeout=self.state.timeouts.file_upload_s,
)
except Exception as e:
raise WorkspaceArchiveWriteError(path=workspace_path, cause=e) from e
async def running(self) -> bool:
try:
await asyncio.wait_for(
self._sandbox.refresh_data(),
timeout=self.state.timeouts.keepalive_s,
)
SandboxState = _import_sandbox_state()
if SandboxState is None:
return False
return bool(getattr(self._sandbox, "state", None) == SandboxState.STARTED)
except Exception:
return False
def _tar_exclude_args(self) -> list[str]:
return shell_tar_exclude_args(self._persist_workspace_skip_relpaths())
@retry_async(
retry_if=lambda exc, self, tar_cmd, tar_path: (
exception_chain_contains_type(exc, _retryable_persist_workspace_error_types())
or exception_chain_has_status_code(exc, TRANSIENT_HTTP_STATUS_CODES)
)
)
async def _run_persist_workspace_command(self, tar_cmd: str, tar_path: str) -> bytes:
try:
envs = await self._resolved_envs()
result = await self._sandbox.process.exec(
tar_cmd,
env=envs or None,
timeout=self.state.timeouts.workspace_tar_s,
)
if result.exit_code != 0:
raise WorkspaceArchiveReadError(
path=self._workspace_root_path(),
context={"reason": "tar_failed", "output": result.result or ""},
retryable=False,
)
return cast(
bytes,
await self._sandbox.fs.download_file(
tar_path,
self.state.timeouts.file_download_s,
),
)
except WorkspaceArchiveReadError:
raise
except Exception as e:
detail = _daytona_provider_error_detail(e)
retryable, reason = _daytona_provider_retryability(e)
context: dict[str, object] = {"backend": "daytona"}
if reason is not None:
context["reason"] = reason
if detail:
context["provider_error"] = detail
provider_error_code = getattr(e, "error_code", None)
if isinstance(provider_error_code, str) and provider_error_code:
context["provider_error_code"] = provider_error_code
raise WorkspaceArchiveReadError(
path=self._workspace_root_path(),
context=context,
cause=e,
retryable=retryable,
) from e
async def persist_workspace(self) -> io.IOBase:
def _error_context_summary(error: WorkspaceArchiveReadError) -> dict[str, str]:
summary = {"message": error.message}
if error.cause is not None:
summary["cause_type"] = type(error.cause).__name__
summary["cause"] = str(error.cause)
return summary
root = self._workspace_root_path()
tar_path = f"/tmp/sandbox-persist-{self.state.session_id.hex}.tar"
excludes = " ".join(self._tar_exclude_args())
tar_cmd = (
f"tar {excludes} -C {shlex.quote(root.as_posix())} -cf {shlex.quote(tar_path)} ."
).strip()
unmounted_mounts: list[tuple[Mount, Path]] = []
unmount_error: WorkspaceArchiveReadError | None = None
for mount_entry, mount_path in self.state.manifest.ephemeral_mount_targets():
try:
await mount_entry.mount_strategy.teardown_for_snapshot(
mount_entry, self, mount_path
)
except Exception as e:
unmount_error = WorkspaceArchiveReadError(path=root, cause=e)
break
unmounted_mounts.append((mount_entry, mount_path))
snapshot_error: WorkspaceArchiveReadError | None = None
raw: bytes | None = None
if unmount_error is None:
try:
raw = await self._run_persist_workspace_command(tar_cmd, tar_path)
except WorkspaceArchiveReadError as e:
snapshot_error = e
finally:
try:
await self._sandbox.process.exec(
f"rm -f -- {shlex.quote(tar_path)}",
timeout=self.state.timeouts.cleanup_s,
)
except Exception:
pass
remount_error: WorkspaceArchiveReadError | None = None
for mount_entry, mount_path in reversed(unmounted_mounts):
try:
await mount_entry.mount_strategy.restore_after_snapshot(
mount_entry, self, mount_path
)
except Exception as e:
current_error = WorkspaceArchiveReadError(path=root, cause=e)
if remount_error is None:
remount_error = current_error
if unmount_error is not None:
remount_error.context["earlier_unmount_error"] = _error_context_summary(
unmount_error
)
else:
additional_remount_errors = remount_error.context.setdefault(
"additional_remount_errors",
[],
)
assert isinstance(additional_remount_errors, list)
additional_remount_errors.append(_error_context_summary(current_error))
if remount_error is not None:
if snapshot_error is not None:
remount_error.context["snapshot_error_before_remount_corruption"] = (
_error_context_summary(snapshot_error)
)
raise remount_error
if unmount_error is not None:
raise unmount_error
if snapshot_error is not None:
raise snapshot_error
assert raw is not None
return io.BytesIO(raw)
async def hydrate_workspace(self, data: io.IOBase) -> None:
root = self._workspace_root_path()
tar_path = f"/tmp/sandbox-hydrate-{self.state.session_id.hex}.tar"
payload = data.read()
if isinstance(payload, str):
payload = payload.encode("utf-8")
if not isinstance(payload, bytes | bytearray):
raise WorkspaceWriteTypeError(path=Path(tar_path), actual_type=type(payload).__name__)
try:
validate_tar_bytes(
bytes(payload),
allow_external_symlink_targets=False,
)
except UnsafeTarMemberError as e:
raise WorkspaceArchiveWriteError(
path=root,
context={
"reason": "unsafe_or_invalid_tar",
"member": e.member,
"detail": str(e),
},
cause=e,
) from e
try:
await self.mkdir(root, parents=True)
envs = await self._resolved_envs()
await self._sandbox.fs.upload_file(
bytes(payload),
tar_path,
timeout=self.state.timeouts.file_upload_s,
)
result = await self._sandbox.process.exec(
f"tar -C {shlex.quote(root.as_posix())} -xf {shlex.quote(tar_path)}",
env=envs or None,
timeout=self.state.timeouts.workspace_tar_s,
)
if result.exit_code != 0:
raise WorkspaceArchiveWriteError(
path=root,
context={"reason": "tar_extract_failed", "output": result.result or ""},
)
except WorkspaceArchiveWriteError:
raise
except Exception as e:
raise WorkspaceArchiveWriteError(path=root, cause=e) from e
finally:
try:
envs = await self._resolved_envs()
await self._sandbox.process.exec(
f"rm -f -- {shlex.quote(tar_path)}",
env=envs or None,
timeout=self.state.timeouts.cleanup_s,
)
except Exception:
pass
class DaytonaSandboxClient(BaseSandboxClient[DaytonaSandboxClientOptions]):
"""Daytona sandbox client managing sandbox lifecycle via AsyncDaytona."""
backend_id = "daytona"
_instrumentation: Instrumentation
def __init__(
self,
*,
api_key: str | None = None,
api_url: str | None = None,
instrumentation: Instrumentation | None = None,
dependencies: Dependencies | None = None,
) -> None:
AsyncDaytona, DaytonaConfig, _, _ = _import_daytona_sdk()
config = DaytonaConfig(api_key=api_key, api_url=api_url) if (api_key or api_url) else None
self._daytona = AsyncDaytona(config)
self._instrumentation = instrumentation or Instrumentation()
self._dependencies = dependencies
async def _build_create_params(
self,
*,
sandbox_snapshot_name: str | None,
image: str | None,
env_vars: dict[str, str] | None,
manifest: Manifest,
name: str | None = None,
resources: DaytonaSandboxResources | None = None,
auto_stop_interval: int | None = None,
) -> Any:
_, _, CreateSandboxFromSnapshotParams, CreateSandboxFromImageParams = _import_daytona_sdk()
base_envs = dict(env_vars or {})
creation_envs = base_envs or None
if sandbox_snapshot_name:
return CreateSandboxFromSnapshotParams(
snapshot=sandbox_snapshot_name,
env_vars=creation_envs,
name=name,
auto_stop_interval=auto_stop_interval,
)
if image:
sandbox_resources = None
if resources is not None and any(
v is not None for v in (resources.cpu, resources.memory, resources.disk)
):
Resources = _import_sdk_resources()
sandbox_resources = Resources(
cpu=resources.cpu,
memory=resources.memory,
disk=resources.disk,
)
return CreateSandboxFromImageParams(
image=image,
env_vars=creation_envs,
name=name,
resources=sandbox_resources,
auto_stop_interval=auto_stop_interval,
)
return CreateSandboxFromSnapshotParams(
env_vars=creation_envs,
name=name,
auto_stop_interval=auto_stop_interval,
)
async def create(
self,
*,
snapshot: SnapshotSpec | SnapshotBase | None = None,
manifest: Manifest | None = None,
options: DaytonaSandboxClientOptions,
) -> SandboxSession:
if manifest is None:
manifest = Manifest(root=DEFAULT_DAYTONA_WORKSPACE_ROOT)
timeouts_in = options.timeouts
if isinstance(timeouts_in, DaytonaSandboxTimeouts):
timeouts = timeouts_in
elif timeouts_in is None:
timeouts = DaytonaSandboxTimeouts()
else:
timeouts = DaytonaSandboxTimeouts.model_validate(timeouts_in)
session_id = uuid.uuid4()
sandbox_name = options.name or str(session_id)
params = await self._build_create_params(
sandbox_snapshot_name=options.sandbox_snapshot_name,
image=options.image,
env_vars=options.env_vars,
manifest=manifest,
name=sandbox_name,
resources=options.resources,
auto_stop_interval=options.auto_stop_interval,
)
daytona_sandbox = await self._daytona.create(params, timeout=options.create_timeout)
snapshot_instance = resolve_snapshot(snapshot, str(session_id))
state = DaytonaSandboxSessionState(
session_id=session_id,
manifest=manifest,
snapshot=snapshot_instance,
sandbox_id=daytona_sandbox.id,
sandbox_snapshot_name=options.sandbox_snapshot_name,
image=options.image,
base_env_vars=dict(options.env_vars or {}),
pause_on_exit=options.pause_on_exit,
create_timeout=options.create_timeout,
start_timeout=options.start_timeout,
name=sandbox_name,
resources=options.resources,
auto_stop_interval=options.auto_stop_interval,
timeouts=timeouts,
exposed_ports=options.exposed_ports,
exposed_port_url_ttl_s=options.exposed_port_url_ttl_s,
)
inner = DaytonaSandboxSession.from_state(state, sandbox=daytona_sandbox)
return self._wrap_session(inner, instrumentation=self._instrumentation)
async def close(self) -> None:
"""Close the underlying AsyncDaytona HTTP client session."""
await self._daytona.close()
async def __aenter__(self) -> DaytonaSandboxClient:
return self
async def __aexit__(self, *_: object) -> None:
await self.close()
async def delete(self, session: SandboxSession) -> SandboxSession:
inner = session._inner
if not isinstance(inner, DaytonaSandboxSession):
raise TypeError("DaytonaSandboxClient.delete expects a DaytonaSandboxSession")
try:
await inner.shutdown()
except Exception:
pass
return session
async def resume(
self,
state: SandboxSessionState,
) -> SandboxSession:
if not isinstance(state, DaytonaSandboxSessionState):
raise TypeError("DaytonaSandboxClient.resume expects a DaytonaSandboxSessionState")
daytona_sandbox = None
reconnected = False
try:
daytona_sandbox = await self._daytona.get(state.sandbox_id)
SandboxState = _import_sandbox_state()
if getattr(daytona_sandbox, "state", None) != SandboxState.STARTED:
await daytona_sandbox.start(timeout=state.start_timeout)
reconnected = True
except Exception as e:
logger.debug("daytona sandbox get() failed, will recreate: %s", e)
if not reconnected or daytona_sandbox is None:
params = await self._build_create_params(
sandbox_snapshot_name=state.sandbox_snapshot_name,
image=state.image,
env_vars=state.base_env_vars,
manifest=state.manifest,
name=state.name,
resources=state.resources,
auto_stop_interval=state.auto_stop_interval,
)
daytona_sandbox = await self._daytona.create(params, timeout=state.create_timeout)
state.sandbox_id = daytona_sandbox.id
state.workspace_root_ready = False
inner = DaytonaSandboxSession.from_state(state, sandbox=daytona_sandbox)
inner._set_start_state_preserved(reconnected, system=reconnected)
return self._wrap_session(inner, instrumentation=self._instrumentation)
def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState:
return DaytonaSandboxSessionState.model_validate(payload)
__all__ = [
"DEFAULT_DAYTONA_WORKSPACE_ROOT",
"DaytonaSandboxResources",
"DaytonaSandboxClient",
"DaytonaSandboxClientOptions",
"DaytonaSandboxSession",
"DaytonaSandboxSessionState",
"DaytonaSandboxTimeouts",
]