Add Daytona Cloud sandbox backend

Add a pluggable Daytona backend (SANDBOX_BACKEND=daytona) that runs code on
Daytona Cloud via the Apache-2.0 SDK and DAYTONA_API_KEY, conforming to the
CodeSandbox interface. Sessions reattach to a labelled cloud sandbox by id
(waking a stopped one) and are created crash-safe so a failed setup never
orphans a paid sandbox; a configurable ceiling caps concurrent sandboxes and
auto-delete is clamped on so orphans always self-reap. File transfer is
workspace-contained with a size guard. Includes mocked unit tests and an
opt-in live smoke test.
This commit is contained in:
Alex committed 2026-06-24 11:56:52 +01:00
1 parent 182a14d257
commit 45f72258f4
6 files changed
+992 -2

No files matched your search

+12 -1
View File
@@ -327,7 +327,7 @@ class Settings(BaseSettings):
# Code-execution sandbox (see artifacts-code-execution-spec.md §4 C2).
# The app is a CLIENT of an always-on runner; defaults are safe so app
# import never fails when the sandbox is unconfigured.
SANDBOX_BACKEND: str = "jupyter" # "jupyter" (self-host) — "daytona" reserved
SANDBOX_BACKEND: str = "jupyter" # "jupyter" (self-host) | "daytona" (Daytona Cloud)
# URL of the Jupyter Kernel Gateway runner (the docsgpt-sandbox service).
SANDBOX_GATEWAY_URL: str = "http://localhost:8888"
SANDBOX_GATEWAY_AUTH_TOKEN: Optional[str] = None # gateway auth token, if set
@@ -342,6 +342,17 @@ class Settings(BaseSettings):
# are part of the untrusted-code security boundary.
SANDBOX_MEMORY: str = "1g" # docker mem_limit for the runner container
SANDBOX_CPUS: str = "1.0" # docker cpu quota for the runner container
# Daytona Cloud managed backend (used only when SANDBOX_BACKEND="daytona").
# The app is a REST client of Daytona Cloud authenticated by DAYTONA_API_KEY;
# all knobs are optional so app import never fails when the backend is unused.
DAYTONA_API_KEY: Optional[str] = None # Daytona Cloud API key (secret)
DAYTONA_API_URL: Optional[str] = None # override Daytona API base URL, if self-targeting
DAYTONA_TARGET: Optional[str] = None # Daytona region/target, e.g. "us"
DAYTONA_SNAPSHOT: Optional[str] = None # snapshot/image id to create sandboxes from
DAYTONA_LANGUAGE: str = "python" # default runtime language for created sandboxes
DAYTONA_AUTO_STOP_INTERVAL: int = 15 # minutes idle before Daytona auto-stops a sandbox (0 disables)
DAYTONA_AUTO_DELETE_INTERVAL: int = 60 # minutes after stop before Daytona auto-deletes (-1 disables)
DAYTONA_MAX_SANDBOXES: int = 50 # cap on concurrent live Daytona sandboxes (cost-DoS guard)
@field_validator("POSTGRES_URI", mode="before")
@classmethod
+1
View File
@@ -10,6 +10,7 @@ celery-redbeat==2.3.3
croniter==6.2.2
cryptography==46.0.7
dataclasses-json==0.6.7
daytona==0.190.1
defusedxml==0.7.1
docling>=2.16.0
rapidocr>=1.4.0
+381
View File
@@ -0,0 +1,381 @@
"""Daytona Cloud sandbox: managed, strongly isolated runtimes via the Apache-2.0 Daytona SDK."""
import logging
import posixpath
import re
import threading
from typing import Dict, List, Optional
from application.sandbox.base import (
CodeSandbox,
ExecResult,
Plot,
)
logger = logging.getLogger(__name__)
# Per-session workspace root inside the Daytona sandbox. Relative file paths
# from LLM code and from put_file/get_file share this single directory.
_WORKSPACE_ROOT = "/home/daytona/docsgpt-sandbox"
# Session ids become filesystem path components and Daytona labels; restrict them.
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
# Label key used to find/reattach the Daytona sandbox bound to a DocsGPT session.
_SESSION_LABEL = "docsgpt_session_id"
class _Handle:
"""Tracks the Daytona sandbox object plus the workspace it executes in."""
def __init__(self, sandbox: object, sandbox_id: str, workspace: str) -> None:
self.sandbox = sandbox
self.sandbox_id = sandbox_id
self.workspace = workspace
class DaytonaSandbox(CodeSandbox):
"""Drives Daytona Cloud as a REST client; one managed sandbox per session.
Exec is STATELESS per call: Daytona's ``process.code_run`` runs each snippet
in a fresh Python interpreter, so Python variables and imports do NOT persist
across ``exec`` calls (unlike the Jupyter backend's stateful kernel). What
DOES persist is the sandbox FILESYSTEM: the per-session workspace and any
files written there survive between calls, so ``put_file``/``get_file`` and
files produced by one ``exec`` are visible to the next. ``attach`` therefore
always returns the same warm sandbox (filesystem state intact, interpreter
state lost). Matplotlib charts emitted by ``code_run`` are captured as PNG
plots. The app is a CLIENT of Daytona Cloud, authenticated by an API key.
Each cloud sandbox carries a ``docsgpt_session_id`` label. ``open``/``attach``
read that label to reattach to a still-live sandbox after a process restart
rather than creating a duplicate and orphaning the old (paid) one. A
``max_sandboxes`` cap bounds concurrent live sandboxes as a cost-DoS guard.
"""
def __init__(
self,
api_key: str,
api_url: Optional[str] = None,
target: Optional[str] = None,
snapshot: Optional[str] = None,
language: str = "python",
default_timeout: float = 60.0,
create_timeout: float = 60.0,
auto_stop_interval: int = 15,
auto_delete_interval: int = 60,
max_file_bytes: int = 10 * 1024 * 1024,
max_sandboxes: int = 50,
) -> None:
"""Configure the Daytona client; no cloud sandbox is created until ``open``."""
if not api_key:
raise ValueError("DAYTONA_API_KEY is required for the daytona sandbox backend")
# Imported lazily so app import never depends on the optional SDK.
from daytona import Daytona, DaytonaConfig
config_kwargs: Dict[str, object] = {"api_key": api_key}
if api_url:
config_kwargs["api_url"] = api_url
if target:
config_kwargs["target"] = target
self._client = Daytona(DaytonaConfig(**config_kwargs))
self._snapshot = snapshot
self._language = language
self._default_timeout = default_timeout
self._create_timeout = create_timeout
self._auto_stop_interval = auto_stop_interval
self._auto_delete_interval = auto_delete_interval
self._max_file_bytes = max_file_bytes
self._max_sandboxes = max_sandboxes
self._handles: Dict[str, _Handle] = {}
self._lock = threading.Lock()
# -- Helpers ---------------------------------------------------------
@staticmethod
def _validate_session_id(session_id: str) -> None:
"""Reject session ids that would not be a safe path component or label value."""
if not _SESSION_ID_RE.match(session_id):
raise ValueError(f"Invalid session id {session_id!r}: expected [A-Za-z0-9_-]+")
def _get_handle(self, session_id: str) -> _Handle:
with self._lock:
handle = self._handles.get(session_id)
if handle is None:
raise KeyError(f"No sandbox session open for {session_id!r}")
return handle
@staticmethod
def _remote_path(workspace: str, rel_path: str) -> str:
"""Join a workspace-relative path, rejecting absolute paths, NUL/control chars, or traversal."""
if any(ord(ch) < 0x20 or ch == "\x7f" for ch in rel_path):
raise ValueError("path contains NUL or control characters")
if rel_path.startswith("/"):
raise ValueError("absolute paths are not allowed")
root = posixpath.normpath(workspace)
resolved = posixpath.normpath(posixpath.join(root, rel_path))
if resolved != root and not resolved.startswith(root + "/"):
raise ValueError("path escapes the session workspace")
return resolved
# -- Lifecycle -------------------------------------------------------
def open(self, session_id: str) -> str:
"""Reattach to or create the Daytona sandbox for ``session_id`` and prime its workspace.
Reattach order: an in-memory handle, then (across process restarts) a live cloud
sandbox carrying the session label, and only then a fresh ``create``. Enforces
``max_sandboxes`` so a flood of sessions cannot run up unbounded paid resources.
"""
self._validate_session_id(session_id)
with self._lock:
existing = self._handles.get(session_id)
if existing is not None:
return existing.sandbox_id
# Cross-restart reattach: an earlier process may have created (and labelled)
# a sandbox for this session that is still live in the cloud. Reuse it
# instead of leaking it behind a brand-new create.
reattached = self._reattach_existing(session_id)
if reattached is not None:
self._prime(reattached)
return reattached.sandbox_id
with self._lock:
if len(self._handles) >= self._max_sandboxes:
raise RuntimeError(
f"Daytona sandbox cap reached ({self._max_sandboxes} live); refusing to create another"
)
sandbox = self._create_sandbox(session_id)
# Crash-safe register: if anything between create and registration raises,
# delete the just-created sandbox so it cannot orphan as a paid resource.
try:
sandbox_id = sandbox.id
handle = _Handle(sandbox, sandbox_id, _WORKSPACE_ROOT)
with self._lock:
self._handles[session_id] = handle
except Exception:
try:
self._client.delete(sandbox)
except Exception as del_exc: # noqa: BLE001 - cleanup is best-effort
logger.warning("Failed to delete orphaned Daytona sandbox during open: %s", del_exc)
raise
self._prime(handle)
return sandbox_id
def _reattach_existing(self, session_id: str) -> Optional["_Handle"]:
"""Find a live cloud sandbox labelled for ``session_id`` and rebuild a handle from it.
Reads the ``docsgpt_session_id`` label written at create time so a process
restart reuses the existing sandbox rather than orphaning it. Returns ``None``
when no live sandbox matches.
"""
try:
from daytona import ListSandboxesQuery, SandboxState
query = ListSandboxesQuery(
labels={_SESSION_LABEL: session_id},
states=[SandboxState.STARTED, SandboxState.STOPPED],
)
matches = list(self._client.list(query))
except Exception as exc: # noqa: BLE001 - listing must never block opening a session
logger.warning("Daytona list for session %s failed; will create fresh: %s", session_id, exc)
return None
for sandbox in matches:
if getattr(sandbox, "labels", {}).get(_SESSION_LABEL) != session_id:
continue
if self._wake_if_stopped(sandbox) is None:
continue
handle = _Handle(sandbox, sandbox.id, _WORKSPACE_ROOT)
with self._lock:
self._handles[session_id] = handle
logger.info("Reattached to existing Daytona sandbox %s for session %s", sandbox.id, session_id)
return handle
return None
def _wake_if_stopped(self, sandbox: object) -> Optional[object]:
"""Start a stopped sandbox so it can serve exec/file ops; return None if it can't be woken."""
state = getattr(sandbox, "state", None)
state_value = getattr(state, "value", state)
if state_value == "started":
return sandbox
try:
self._client.start(sandbox, timeout=self._create_timeout)
return sandbox
except Exception as exc: # noqa: BLE001 - a sandbox we can't start is unusable
logger.warning("Failed to start stopped Daytona sandbox %s: %s", getattr(sandbox, "id", "?"), exc)
return None
def _create_sandbox(self, session_id: str):
"""Create a fresh Daytona sandbox labelled for ``session_id``."""
from daytona import CreateSandboxFromSnapshotParams
params_kwargs: Dict[str, object] = {
"language": self._language,
"labels": {_SESSION_LABEL: session_id},
"auto_stop_interval": self._auto_stop_interval,
"auto_delete_interval": self._auto_delete_interval,
}
if self._snapshot:
params_kwargs["snapshot"] = self._snapshot
params = CreateSandboxFromSnapshotParams(**params_kwargs)
return self._client.create(params, timeout=self._create_timeout)
def attach(self, session_id: str) -> str:
"""Reattach to the sandbox for ``session_id``; filesystem state is preserved.
Prefers the in-memory handle, then a live labelled cloud sandbox (so a process
restart does not orphan it), and only opens a fresh one as a last resort.
"""
self._validate_session_id(session_id)
with self._lock:
existing = self._handles.get(session_id)
if existing is not None:
return existing.sandbox_id
logger.warning("No live handle for session %s; reattaching or opening a Daytona sandbox", session_id)
return self.open(session_id)
def close(self, session_id: str) -> None:
"""Delete the Daytona sandbox for ``session_id`` so no cloud resource leaks."""
with self._lock:
handle = self._handles.pop(session_id, None)
if handle is None:
return
try:
self._client.delete(handle.sandbox)
except Exception as exc: # noqa: BLE001 - teardown is best-effort, never raise
logger.warning("Failed to delete Daytona sandbox %s: %s", handle.sandbox_id, exc)
def _prime(self, handle: _Handle) -> None:
"""Create the per-session workspace directory inside the sandbox."""
try:
handle.sandbox.fs.create_folder(handle.workspace, "755")
except Exception as exc: # noqa: BLE001 - folder may already exist on a reused snapshot
logger.debug("Workspace folder prime returned: %s", exc)
# -- Execution -------------------------------------------------------
def exec(self, session_id: str, code: str, timeout: Optional[float] = None) -> ExecResult:
"""Run ``code`` via Daytona ``code_run``; per-call interpreter, persistent filesystem."""
handle = self._get_handle(session_id)
wall = int(timeout or self._default_timeout)
wrapped = self._with_workspace_cwd(handle.workspace, code)
try:
response = handle.sandbox.process.code_run(wrapped, timeout=wall)
except Exception as exc: # noqa: BLE001 - any SDK/cloud error -> error result, never raise
return ExecResult(
status="error",
error_name=type(exc).__name__,
error_value=str(exc) or "code_run failed",
exit_code=-1,
)
return self._to_result(response)
@staticmethod
def _with_workspace_cwd(workspace: str, code: str) -> str:
"""Prepend a chdir into the session workspace so relative paths resolve there."""
prelude = (
"import os as _os\n"
f"_os.makedirs({workspace!r}, exist_ok=True)\n"
f"_os.chdir({workspace!r})\n"
)
return prelude + code
@staticmethod
def _to_result(response) -> ExecResult:
"""Map a Daytona ``ExecuteResponse`` into the shared ``ExecResult`` shape."""
exit_code = getattr(response, "exit_code", 0) or 0
artifacts = getattr(response, "artifacts", None)
stdout = ""
if artifacts is not None and getattr(artifacts, "stdout", None) is not None:
stdout = artifacts.stdout
else:
stdout = getattr(response, "result", "") or ""
result = ExecResult(
status="ok" if exit_code == 0 else "error",
stdout=stdout,
exit_code=exit_code,
)
if exit_code != 0:
result.error_name = "ExecutionError"
result.error_value = stdout or f"exited with code {exit_code}"
if artifacts is not None:
for chart in getattr(artifacts, "charts", None) or []:
png = getattr(chart, "png", None)
if png:
result.plots.append(Plot(format="png", content_base64=png))
return result
# -- File transfer ---------------------------------------------------
def put_file(self, session_id: str, dest_path: str, data: bytes) -> None:
"""Upload ``data`` to ``dest_path`` under the session workspace, creating parent dirs."""
handle = self._get_handle(session_id)
remote = self._remote_path(handle.workspace, dest_path)
parent = posixpath.dirname(remote)
try:
if parent and parent != handle.workspace:
try:
handle.sandbox.fs.create_folder(parent, "755")
except Exception as folder_exc: # noqa: BLE001 - folder may already exist
logger.debug("put_file parent folder create returned: %s", folder_exc)
handle.sandbox.fs.upload_file(data, remote)
except Exception as exc: # noqa: BLE001 - log detail server-side, return a generic error
logger.warning("put_file failed for %r: %s", dest_path, exc)
raise IOError(f"put_file failed: {type(exc).__name__}") from exc
def get_file(self, session_id: str, path: str) -> bytes:
"""Download ``path`` from the session workspace as bytes, capped at ``max_file_bytes``."""
handle = self._get_handle(session_id)
remote = self._remote_path(handle.workspace, path)
try:
info = handle.sandbox.fs.get_file_info(remote)
size = getattr(info, "size", None)
if size is not None and size > self._max_file_bytes:
raise IOError(f"file too large: {size} > {self._max_file_bytes} bytes")
data = handle.sandbox.fs.download_file(remote)
except IOError:
raise
except Exception as exc: # noqa: BLE001 - log detail server-side, return a generic error
logger.warning("get_file failed for %r: %s", path, exc)
raise IOError(f"get_file failed: {type(exc).__name__}") from exc
if data is None:
raise IOError(f"get_file produced no payload for {path!r}")
data = data if isinstance(data, bytes) else bytes(data)
# The pre-download size guard may be skipped when get_file_info has no size;
# enforce the cap against the actual payload so an oversized file never slips through.
if len(data) > self._max_file_bytes:
raise IOError(f"file too large: {len(data)} > {self._max_file_bytes} bytes")
return data
def list_files(self, session_id: str) -> List[str]:
"""List workspace-relative file paths for ``session_id`` (recursive, never escapes the workspace)."""
handle = self._get_handle(session_id)
out: List[str] = []
self._walk(handle.sandbox, handle.workspace, "", out)
return out
def _walk(self, sandbox: object, root: str, rel_dir: str, out: List[str]) -> None:
"""Recurse one workspace subtree, appending workspace-relative file paths to ``out``."""
abs_dir = posixpath.join(root, rel_dir) if rel_dir else root
try:
entries = sandbox.fs.list_files(abs_dir)
except Exception as exc: # noqa: BLE001 - log detail server-side, return a generic error
logger.warning("list_files failed for %r: %s", rel_dir or ".", exc)
raise IOError(f"list_files failed: {type(exc).__name__}") from exc
for entry in entries or []:
name = getattr(entry, "name", None)
if not name:
continue
child_rel = posixpath.join(rel_dir, name) if rel_dir else name
# Defend against a backend returning ".."/absolute entries that would escape root.
resolved = posixpath.normpath(posixpath.join(root, child_rel))
if resolved != root and not resolved.startswith(root + "/"):
continue
if getattr(entry, "is_dir", False):
self._walk(sandbox, root, child_rel, out)
else:
out.append(child_rel)
+26 -1
View File
@@ -21,12 +21,37 @@ def _make_jupyter() -> CodeSandbox:
)
def _make_daytona() -> CodeSandbox:
"""Build the Daytona Cloud backend from ``DAYTONA_*``/``SANDBOX_*`` settings."""
from application.sandbox.daytona import DaytonaSandbox
# Auto-delete is the only backstop against orphaned (paid) sandboxes, so a
# never-expiring value (<= 0) is rejected and clamped to a safe default.
auto_delete_interval = int(settings.DAYTONA_AUTO_DELETE_INTERVAL)
if auto_delete_interval <= 0:
auto_delete_interval = 60
return DaytonaSandbox(
api_key=settings.DAYTONA_API_KEY,
api_url=settings.DAYTONA_API_URL,
target=settings.DAYTONA_TARGET,
snapshot=settings.DAYTONA_SNAPSHOT,
language=settings.DAYTONA_LANGUAGE,
default_timeout=float(settings.SANDBOX_EXEC_TIMEOUT),
create_timeout=float(settings.SANDBOX_HTTP_TIMEOUT) * 6,
auto_stop_interval=int(settings.DAYTONA_AUTO_STOP_INTERVAL),
auto_delete_interval=auto_delete_interval,
max_file_bytes=int(settings.SANDBOX_MAX_FILE_BYTES),
max_sandboxes=int(settings.DAYTONA_MAX_SANDBOXES),
)
class SandboxCreator:
"""Resolves ``SANDBOX_BACKEND`` to a backend and caches a single manager."""
# Seam for a future "daytona" backend (Apache-2.0 SDK); not implemented here.
backends: Dict[str, Callable[[], CodeSandbox]] = {
"jupyter": _make_jupyter,
"daytona": _make_daytona,
}
_instance = None
+522
View File
@@ -0,0 +1,522 @@
"""Unit tests for DaytonaSandbox with the Daytona SDK fully mocked (no cloud calls)."""
import sys
import types
from unittest import mock
import pytest
from application.sandbox.base import ExecResult
# --- Fakes mirroring the real Daytona SDK shapes -------------------------
class _FakeArtifacts:
def __init__(self, stdout="", charts=None):
self.stdout = stdout
self.charts = charts or []
class _FakeChart:
def __init__(self, png):
self.png = png
class _FakeExecuteResponse:
def __init__(self, exit_code=0, result="", artifacts=None):
self.exit_code = exit_code
self.result = result
self.artifacts = artifacts
class _FakeFileInfo:
def __init__(self, name, is_dir=False, size=0):
self.name = name
self.is_dir = is_dir
self.size = size
class _FakeProcess:
def __init__(self):
self.code_run = mock.Mock(return_value=_FakeExecuteResponse(exit_code=0, result="hi"))
class _FakeFS:
def __init__(self):
self.create_folder = mock.Mock()
self.upload_file = mock.Mock()
self.download_file = mock.Mock(return_value=b"payload")
self.get_file_info = mock.Mock(return_value=_FakeFileInfo("a.txt", size=7))
# Path-aware tree: maps an absolute dir to the entries it contains. The
# real Daytona fs.list_files is single-level and returns basenames, so the
# backend must recurse into subdirs itself. Default tree is a single file.
self.tree = {}
self.list_files = mock.Mock(side_effect=self._list_files)
def _list_files(self, path):
if self.tree:
return self.tree.get(path, [])
return [_FakeFileInfo("a.txt")]
class _FakeSandbox:
def __init__(self, sandbox_id="sbx-1", labels=None, state="started"):
self.id = sandbox_id
self.labels = labels or {}
self.state = state
self.process = _FakeProcess()
self.fs = _FakeFS()
class _FakeDaytonaClient:
def __init__(self, config=None):
self.config = config
self.created = []
self.deleted = []
self.started = []
# Sandboxes pretend to already exist in the cloud (cross-restart reattach).
self.existing = []
self.create = mock.Mock(side_effect=self._create)
self.delete = mock.Mock(side_effect=self._delete)
self.list = mock.Mock(side_effect=self._list)
self.start = mock.Mock(side_effect=self._start)
def _create(self, params=None, timeout=60):
labels = getattr(params, "labels", None) or {}
sandbox = _FakeSandbox(sandbox_id=f"sbx-{len(self.created) + 1}", labels=labels)
self.created.append((params, sandbox))
return sandbox
def _delete(self, sandbox, timeout=60):
self.deleted.append(sandbox)
def _list(self, query=None):
wanted = getattr(query, "labels", None) or {}
for sandbox in self.existing:
labels = getattr(sandbox, "labels", {})
if all(labels.get(k) == v for k, v in wanted.items()):
yield sandbox
def _start(self, sandbox, timeout=60):
self.started.append(sandbox)
sandbox.state = "started"
@pytest.fixture()
def fake_sdk(monkeypatch):
"""Install a fake ``daytona`` module so DaytonaSandbox imports it without the real SDK."""
captured = {}
def _config(**kwargs):
captured["config"] = kwargs
return types.SimpleNamespace(**kwargs)
def _params(**kwargs):
return types.SimpleNamespace(**kwargs)
def _list_query(**kwargs):
return types.SimpleNamespace(**kwargs)
class _SandboxState:
STARTED = "started"
STOPPED = "stopped"
fake_module = types.ModuleType("daytona")
fake_module.Daytona = _FakeDaytonaClient
fake_module.DaytonaConfig = _config
fake_module.CreateSandboxFromSnapshotParams = _params
fake_module.ListSandboxesQuery = _list_query
fake_module.SandboxState = _SandboxState
monkeypatch.setitem(sys.modules, "daytona", fake_module)
captured["client_cls"] = _FakeDaytonaClient
return captured
@pytest.fixture()
def sandbox(fake_sdk):
from application.sandbox.daytona import DaytonaSandbox
return DaytonaSandbox(api_key="dtn_test", language="python")
# --- Construction --------------------------------------------------------
def test_requires_api_key(fake_sdk):
from application.sandbox.daytona import DaytonaSandbox
with pytest.raises(ValueError):
DaytonaSandbox(api_key="")
def test_config_forwards_optional_knobs(fake_sdk):
from application.sandbox.daytona import DaytonaSandbox
DaytonaSandbox(api_key="k", api_url="https://api.example", target="us")
cfg = fake_sdk["config"]
assert cfg["api_key"] == "k"
assert cfg["api_url"] == "https://api.example"
assert cfg["target"] == "us"
def test_config_omits_unset_knobs(fake_sdk):
from application.sandbox.daytona import DaytonaSandbox
DaytonaSandbox(api_key="k")
assert set(fake_sdk["config"].keys()) == {"api_key"}
# --- Lifecycle -----------------------------------------------------------
def test_open_creates_sandbox_and_primes_workspace(sandbox):
handle_id = sandbox.open("conv-1")
assert handle_id == "sbx-1"
assert sandbox._client.create.call_count == 1
_, created = sandbox._client.created[0]
created.fs.create_folder.assert_called_once()
# label binds the cloud sandbox to the docsgpt session id
params = sandbox._client.create.call_args.args[0]
assert params.labels["docsgpt_session_id"] == "conv-1"
def test_open_twice_reuses_handle(sandbox):
sandbox.open("conv-1")
sandbox.open("conv-1")
assert sandbox._client.create.call_count == 1 # created once
def test_attach_reuses_warm_handle(sandbox):
sandbox.open("conv-1")
assert sandbox.attach("conv-1") == "sbx-1"
assert sandbox._client.create.call_count == 1
def test_attach_opens_when_no_handle(sandbox):
assert sandbox.attach("conv-1") == "sbx-1"
assert sandbox._client.create.call_count == 1
def test_close_deletes_cloud_sandbox(sandbox):
sandbox.open("conv-1")
_, created = sandbox._client.created[0]
sandbox.close("conv-1")
sandbox._client.delete.assert_called_once_with(created)
assert not sandbox._handles
def test_close_unknown_session_is_noop(sandbox):
sandbox.close("never-opened")
sandbox._client.delete.assert_not_called()
def test_close_swallows_delete_errors(sandbox):
sandbox.open("conv-1")
sandbox._client.delete.side_effect = RuntimeError("cloud down")
sandbox.close("conv-1") # must not raise
assert not sandbox._handles
def test_invalid_session_id_rejected(sandbox):
with pytest.raises(ValueError):
sandbox.open("../etc/passwd")
def test_open_reattaches_to_existing_cloud_sandbox(sandbox):
"""A still-live labelled sandbox (e.g. after a process restart) is reused, not re-created."""
prior = _FakeSandbox(sandbox_id="sbx-prior", labels={"docsgpt_session_id": "conv-1"})
sandbox._client.existing = [prior]
handle_id = sandbox.open("conv-1")
assert handle_id == "sbx-prior"
assert sandbox._client.create.call_count == 0 # no new paid sandbox
assert sandbox._handles["conv-1"].sandbox is prior
def test_open_reattach_starts_stopped_sandbox(sandbox):
prior = _FakeSandbox(sandbox_id="sbx-stopped", labels={"docsgpt_session_id": "conv-1"}, state="stopped")
sandbox._client.existing = [prior]
sandbox.open("conv-1")
sandbox._client.start.assert_called_once()
assert prior.state == "started"
assert sandbox._client.create.call_count == 0
def test_open_ignores_existing_for_other_session(sandbox):
other = _FakeSandbox(sandbox_id="sbx-other", labels={"docsgpt_session_id": "conv-OTHER"})
sandbox._client.existing = [other]
handle_id = sandbox.open("conv-1")
assert handle_id == "sbx-1" # a fresh one is created
assert sandbox._client.create.call_count == 1
def test_open_enforces_concurrency_cap(fake_sdk):
from application.sandbox.daytona import DaytonaSandbox
s = DaytonaSandbox(api_key="k", max_sandboxes=2)
s.open("conv-1")
s.open("conv-2")
with pytest.raises(RuntimeError):
s.open("conv-3")
assert s._client.create.call_count == 2
def test_open_deletes_sandbox_if_registration_fails(sandbox):
"""Crash between create and register must delete the orphaned cloud sandbox."""
class _BoomSandbox(_FakeSandbox):
def __init__(self):
super().__init__(sandbox_id="sbx-boom")
@property
def id(self):
raise RuntimeError("id blew up")
@id.setter
def id(self, value):
pass # absorb _FakeSandbox.__init__'s assignment
boom = _BoomSandbox()
sandbox._client.create.side_effect = lambda params=None, timeout=60: boom
with pytest.raises(RuntimeError):
sandbox.open("conv-1")
sandbox._client.delete.assert_called_once_with(boom)
assert "conv-1" not in sandbox._handles
# --- Execution -----------------------------------------------------------
def test_exec_maps_success_to_exec_result(sandbox):
sandbox.open("conv-1")
_, created = sandbox._client.created[0]
created.process.code_run.return_value = _FakeExecuteResponse(
exit_code=0, result="ignored", artifacts=_FakeArtifacts(stdout="42\n")
)
res = sandbox.exec("conv-1", "print(42)")
assert isinstance(res, ExecResult)
assert res.ok and res.stdout == "42\n" and res.exit_code == 0
def test_exec_prepends_workspace_chdir(sandbox):
sandbox.open("conv-1")
_, created = sandbox._client.created[0]
sandbox.exec("conv-1", "print('x')")
sent_code = created.process.code_run.call_args.args[0]
assert "_os.chdir" in sent_code and "print('x')" in sent_code
def test_exec_nonzero_exit_is_error(sandbox):
sandbox.open("conv-1")
_, created = sandbox._client.created[0]
created.process.code_run.return_value = _FakeExecuteResponse(
exit_code=1, artifacts=_FakeArtifacts(stdout="Traceback...")
)
res = sandbox.exec("conv-1", "raise ValueError()")
assert res.status == "error" and res.exit_code == 1
assert res.error_name == "ExecutionError"
def test_exec_captures_charts_as_plots(sandbox):
sandbox.open("conv-1")
_, created = sandbox._client.created[0]
created.process.code_run.return_value = _FakeExecuteResponse(
exit_code=0, artifacts=_FakeArtifacts(stdout="", charts=[_FakeChart("BASE64PNG")])
)
res = sandbox.exec("conv-1", "plt.plot(...)")
assert len(res.plots) == 1
assert res.plots[0].format == "png" and res.plots[0].content_base64 == "BASE64PNG"
def test_exec_sdk_error_becomes_error_result(sandbox):
sandbox.open("conv-1")
_, created = sandbox._client.created[0]
created.process.code_run.side_effect = RuntimeError("boom")
res = sandbox.exec("conv-1", "1/0")
assert res.status == "error" and res.error_name == "RuntimeError"
assert res.exit_code == -1
def test_exec_requires_open_session(sandbox):
with pytest.raises(KeyError):
sandbox.exec("missing", "1+1")
# --- File transfer -------------------------------------------------------
def test_put_file_uploads_under_workspace(sandbox):
sandbox.open("conv-1")
_, created = sandbox._client.created[0]
sandbox.put_file("conv-1", "out/data.csv", b"a,b\n")
data, remote = created.fs.upload_file.call_args.args
assert data == b"a,b\n"
assert remote.endswith("/docsgpt-sandbox/out/data.csv")
def test_put_file_creates_parent_dirs(sandbox):
sandbox.open("conv-1")
_, created = sandbox._client.created[0]
created.fs.create_folder.reset_mock()
sandbox.put_file("conv-1", "out/nested/data.csv", b"x")
folder = created.fs.create_folder.call_args.args[0]
assert folder.endswith("/docsgpt-sandbox/out/nested")
def test_put_file_swallows_existing_folder_error(sandbox):
sandbox.open("conv-1")
_, created = sandbox._client.created[0]
created.fs.create_folder.side_effect = RuntimeError("already exists")
sandbox.put_file("conv-1", "out/data.csv", b"x") # must not raise
created.fs.upload_file.assert_called_once()
def test_put_file_rejects_nul_path(sandbox):
sandbox.open("conv-1")
with pytest.raises(ValueError):
sandbox.put_file("conv-1", "bad\x00name.csv", b"x")
def test_put_file_error_wrapped_as_ioerror(sandbox):
sandbox.open("conv-1")
_, created = sandbox._client.created[0]
created.fs.upload_file.side_effect = RuntimeError("internal://api.daytona/upload")
with pytest.raises(IOError) as exc:
sandbox.put_file("conv-1", "data.csv", b"x")
assert "api.daytona" not in str(exc.value)
def test_get_file_error_wrapped_as_ioerror(sandbox):
sandbox.open("conv-1")
_, created = sandbox._client.created[0]
created.fs.download_file.side_effect = RuntimeError("internal://api.daytona/download")
with pytest.raises(IOError) as exc:
sandbox.get_file("conv-1", "a.txt")
assert "api.daytona" not in str(exc.value)
def test_put_file_rejects_absolute_path(sandbox):
sandbox.open("conv-1")
with pytest.raises(ValueError):
sandbox.put_file("conv-1", "/etc/passwd", b"x")
def test_put_file_rejects_traversal(sandbox):
sandbox.open("conv-1")
with pytest.raises(ValueError):
sandbox.put_file("conv-1", "../../escape", b"x")
def test_get_file_returns_bytes(sandbox):
sandbox.open("conv-1")
_, created = sandbox._client.created[0]
created.fs.download_file.return_value = b"hello"
created.fs.get_file_info.return_value = _FakeFileInfo("a.txt", size=5)
assert sandbox.get_file("conv-1", "a.txt") == b"hello"
def test_get_file_too_large_rejected(sandbox):
from application.sandbox.daytona import DaytonaSandbox
s = DaytonaSandbox(api_key="k", max_file_bytes=3)
s.open("conv-1")
_, created = s._client.created[0]
created.fs.get_file_info.return_value = _FakeFileInfo("a.txt", size=99)
with pytest.raises(IOError):
s.get_file("conv-1", "a.txt")
def test_get_file_post_download_size_guard(sandbox):
"""Oversized payloads are rejected even when get_file_info reports no size."""
from application.sandbox.daytona import DaytonaSandbox
s = DaytonaSandbox(api_key="k", max_file_bytes=3)
s.open("conv-1")
_, created = s._client.created[0]
created.fs.get_file_info.return_value = _FakeFileInfo("a.txt", size=None)
created.fs.download_file.return_value = b"way-too-long"
with pytest.raises(IOError):
s.get_file("conv-1", "a.txt")
def test_get_file_none_payload_raises(sandbox):
sandbox.open("conv-1")
_, created = sandbox._client.created[0]
created.fs.download_file.return_value = None
with pytest.raises(IOError):
sandbox.get_file("conv-1", "a.txt")
_WS = "/home/daytona/docsgpt-sandbox"
def test_list_files_returns_top_level_files(sandbox):
sandbox.open("conv-1")
_, created = sandbox._client.created[0]
created.fs.tree = {
_WS: [_FakeFileInfo("a.txt"), _FakeFileInfo("nested", is_dir=True), _FakeFileInfo("b.json")],
f"{_WS}/nested": [],
}
assert sorted(sandbox.list_files("conv-1")) == ["a.txt", "b.json"]
def test_list_files_recurses_into_subdirs(sandbox):
sandbox.open("conv-1")
_, created = sandbox._client.created[0]
created.fs.tree = {
_WS: [_FakeFileInfo("top.txt"), _FakeFileInfo("sub", is_dir=True)],
f"{_WS}/sub": [_FakeFileInfo("a.txt"), _FakeFileInfo("deep", is_dir=True)],
f"{_WS}/sub/deep": [_FakeFileInfo("c.bin")],
}
assert sorted(sandbox.list_files("conv-1")) == ["sub/a.txt", "sub/deep/c.bin", "top.txt"]
def test_list_files_error_wrapped_as_ioerror(sandbox):
sandbox.open("conv-1")
_, created = sandbox._client.created[0]
created.fs.list_files.side_effect = RuntimeError("internal://api.daytona/list")
with pytest.raises(IOError) as exc:
sandbox.list_files("conv-1")
# generic error: no internal endpoint leaked to the model
assert "api.daytona" not in str(exc.value)
# --- Registry wiring -----------------------------------------------------
def test_sandbox_creator_selects_daytona_backend(fake_sdk, monkeypatch):
from application.core.settings import settings
from application.sandbox import sandbox_creator as sc
from application.sandbox.daytona import DaytonaSandbox
monkeypatch.setattr(settings, "DAYTONA_API_KEY", "dtn_test", raising=False)
sc.SandboxCreator.reset()
backend = sc.SandboxCreator.create_backend("daytona")
assert isinstance(backend, DaytonaSandbox)
sc.SandboxCreator.reset()
@pytest.mark.parametrize("configured", [0, -1, -5])
def test_factory_clamps_nonpositive_auto_delete_interval(fake_sdk, monkeypatch, configured):
"""A never-expiring auto_delete_interval (<= 0) is replaced so orphans always expire."""
from application.core.settings import settings
from application.sandbox import sandbox_creator as sc
monkeypatch.setattr(settings, "DAYTONA_API_KEY", "dtn_test", raising=False)
monkeypatch.setattr(settings, "DAYTONA_AUTO_DELETE_INTERVAL", configured, raising=False)
sc.SandboxCreator.reset()
backend = sc.SandboxCreator.create_backend("daytona")
assert backend._auto_delete_interval > 0
sc.SandboxCreator.reset()
def test_factory_forwards_max_sandboxes(fake_sdk, monkeypatch):
from application.core.settings import settings
from application.sandbox import sandbox_creator as sc
monkeypatch.setattr(settings, "DAYTONA_API_KEY", "dtn_test", raising=False)
monkeypatch.setattr(settings, "DAYTONA_MAX_SANDBOXES", 7, raising=False)
sc.SandboxCreator.reset()
backend = sc.SandboxCreator.create_backend("daytona")
assert backend._max_sandboxes == 7
sc.SandboxCreator.reset()
+50
View File
@@ -0,0 +1,50 @@
"""Optional live smoke test against real Daytona Cloud; skipped unless explicitly enabled.
Run with both flags set so CI/normal runs never burn cloud quota::
RUN_DAYTONA_LIVE=1 DAYTONA_API_KEY=dtn_... .venv/bin/python -m pytest \
tests/sandbox/test_daytona_live.py -q --no-cov
"""
import os
import uuid
import pytest
pytest.importorskip("daytona")
_ENABLED = os.environ.get("RUN_DAYTONA_LIVE") == "1" and bool(os.environ.get("DAYTONA_API_KEY"))
pytestmark = pytest.mark.skipif(
not _ENABLED,
reason="set RUN_DAYTONA_LIVE=1 and DAYTONA_API_KEY to run the live Daytona smoke test",
)
@pytest.fixture()
def live_sandbox():
from application.sandbox.daytona import DaytonaSandbox
s = DaytonaSandbox(
api_key=os.environ["DAYTONA_API_KEY"],
api_url=os.environ.get("DAYTONA_API_URL") or None,
target=os.environ.get("DAYTONA_TARGET") or None,
)
session_id = "smoke" + uuid.uuid4().hex[:8]
s.open(session_id)
try:
yield s, session_id
finally:
s.close(session_id) # always delete the cloud sandbox so we never leak cost
def test_live_exec_and_file_roundtrip(live_sandbox):
s, session_id = live_sandbox
res = s.exec(session_id, "print(6 * 7)")
assert res.ok, res.error_value
assert "42" in res.stdout
s.put_file(session_id, "hello.txt", b"daytona-smoke")
assert s.get_file(session_id, "hello.txt") == b"daytona-smoke"
assert "hello.txt" in s.list_files(session_id)