"""Remote sandbox adapters (Daytona / E2B) for :class:`SandboxProviderPort`. This is the **only** place tenant code may execute. It never shells out on the platform host: every command is sent to a remote, disposable sandbox through the provider's official Python SDK. Why the SDKs are optional extras -------------------------------- ``daytona`` and ``e2b`` are large, fast-moving dependencies. They are declared as the ``sandbox-remote`` extra so a default install stays small, and they are imported lazily inside :meth:`_load_sdk`. A missing extra produces an actionable ``ProviderUnavailableError`` instead of an ``ImportError`` at startup. Fail loudly, not silently ------------------------- The two SDKs change their surface between minor releases. Rather than assume a method exists and fail halfway through a customer's build, :meth:`preflight` probes for every attribute this adapter relies on and refuses to construct the adapter if any is missing. A version drift therefore surfaces as a clear configuration error the moment ``SANDBOX_PROVIDER`` is switched on. Known gap --------- Provider-native snapshots (a resumable VM image) are **not** wired yet. Calling :meth:`snapshot` raises a clear error rather than pretending to have saved one. The mock provider implements snapshots fully, and the database already records them, so only the remote path is affected. """ from __future__ import annotations import time from typing import Any from app.application.ports import CommandResult, SessionInfo, SnapshotInfo, WorkspaceInfo from app.core.errors import ProviderUnavailableError #: Never let a caller ask for an unbounded run. ABSOLUTE_MAX_TIMEOUT_SECONDS = 900 #: Mirror of the platform output cap; enforced client-side as well because a #: remote SDK is under no obligation to truncate. ABSOLUTE_MAX_OUTPUT_BYTES = 2_000_000 def _load_sdk(provider: str) -> Any: """Import the provider SDK, or explain exactly how to install it.""" try: if provider == "daytona": import daytona # noqa: PLC0415 return daytona if provider == "e2b": import e2b # noqa: PLC0415 return e2b except ImportError as exc: raise ProviderUnavailableError( f"SDK «{provider}» نصب نیست. برای فعال‌سازی اجرا کنید: " f"pip install 'agy-web-api[sandbox-remote]'" ) from exc raise ProviderUnavailableError(f"ارائه‌دهندهٔ sandbox «{provider}» پشتیبانی نمی‌شود.") def _require(obj: Any, attr: str, *, provider: str, where: str) -> Any: """Return ``obj.attr`` or fail with a precise version-mismatch message.""" if not hasattr(obj, attr): raise ProviderUnavailableError( f"SDK «{provider}» با این نسخه سازگار نیست: «{where}.{attr}» یافت نشد. " "نسخهٔ پشتیبانی‌شده را نصب کنید یا SANDBOX_PROVIDER=mock بگذارید." ) return getattr(obj, attr) def _truncate(text: str, limit: int) -> tuple[str, bool]: encoded = text.encode("utf-8", errors="replace") if len(encoded) <= limit: return text, False return encoded[:limit].decode("utf-8", errors="ignore"), True class _RemoteSandboxAdapter: """Shared behaviour for both remote providers.""" provider_name = "remote" def __init__(self, *, max_output_bytes: int = 200_000) -> None: self.max_output_bytes = min(max_output_bytes, ABSOLUTE_MAX_OUTPUT_BYTES) self._client: Any = None self._sandboxes: dict[str, Any] = {} self._sessions: dict[str, str] = {} # -- lifecycle ----------------------------------------------------------- async def _ensure_client(self) -> Any: # pragma: no cover - overridden raise NotImplementedError async def create_workspace( self, *, organization_id: str, project_id: str, template_id: str, image: str ) -> WorkspaceInfo: client = await self._ensure_client() sandbox = await self._create_sandbox(client, image=image, template_id=template_id) external_id = str( _require(sandbox, "sandbox_id", provider=self.provider_name, where="sandbox") ) self._sandboxes[external_id] = sandbox return WorkspaceInfo( external_id=external_id, status="ready", raw={"provider": self.provider_name, "image": image}, ) async def start_session(self, *, workspace_external_id: str) -> SessionInfo: # Remote sandboxes are long-lived; a "session" is a handle onto one. external_id = f"ses_{workspace_external_id}" self._sessions[external_id] = workspace_external_id return SessionInfo(external_id=external_id, status="running") async def _sandbox_for_session(self, session_external_id: str) -> Any: workspace_id = self._sessions.get(session_external_id, session_external_id) sandbox = self._sandboxes.get(workspace_id) if sandbox is None: raise ProviderUnavailableError("نشست sandbox یافت نشد یا منقضی شده است.") return sandbox # -- execution ----------------------------------------------------------- async def run_command( self, *, session_external_id: str, command: str, timeout_seconds: int, max_output_bytes: int | None = None, env: dict[str, str] | None = None, ) -> CommandResult: sandbox = await self._sandbox_for_session(session_external_id) limit = min(max_output_bytes or self.max_output_bytes, ABSOLUTE_MAX_OUTPUT_BYTES) # Clamp here too: the port contract says the caller can never ask for # "no limit", and a bug upstream must not become an unbounded remote run. timeout = max(1, min(int(timeout_seconds), ABSOLUTE_MAX_TIMEOUT_SECONDS)) started = time.perf_counter() raw = await self._exec(sandbox, command=command, timeout=timeout, env=env or {}) duration_ms = max(1, int((time.perf_counter() - started) * 1000)) # Pull the three fields out defensively; a missing one is a version # mismatch and must fail with a precise message. exit_code = _require( raw, "exit_code", provider=self.provider_name, where="command result" ) raw_stdout = _require( raw, "stdout", provider=self.provider_name, where="command result" ) raw_stderr = _require( raw, "stderr", provider=self.provider_name, where="command result" ) stdout, cut_out = _truncate(str(raw_stdout or ""), limit) stderr, cut_err = _truncate(str(raw_stderr or ""), limit) return CommandResult( exit_code=int(exit_code or 0), stdout=stdout, stderr=stderr, duration_ms=duration_ms, truncated=cut_out or cut_err, ) # -- files --------------------------------------------------------------- async def read_file(self, *, session_external_id: str, path: str) -> str: sandbox = await self._sandbox_for_session(session_external_id) return await self._read(sandbox, path) async def write_file(self, *, session_external_id: str, path: str, content: str) -> None: sandbox = await self._sandbox_for_session(session_external_id) await self._write(sandbox, path, content) # -- snapshots / preview ------------------------------------------------- async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo: raise ProviderUnavailableError( "snapshot بومی ارائه‌دهندهٔ راه‌دور هنوز فعال نشده است. " "برای snapshot از SANDBOX_PROVIDER=mock استفاده کنید یا این قابلیت را " "برای ارائه‌دهندهٔ خود پیاده‌سازی کنید." ) async def pause(self, *, session_external_id: str) -> None: sandbox = await self._sandbox_for_session(session_external_id) await self._pause(sandbox) async def resume(self, *, session_external_id: str) -> SessionInfo: sandbox = await self._sandbox_for_session(session_external_id) await self._resume(sandbox) return SessionInfo(external_id=session_external_id, status="running") async def destroy(self, *, session_external_id: str) -> None: workspace_id = self._sessions.pop(session_external_id, session_external_id) sandbox = self._sandboxes.pop(workspace_id, None) if sandbox is not None: await self._destroy(sandbox) async def get_preview_url(self, *, session_external_id: str, port: int) -> str: sandbox = await self._sandbox_for_session(session_external_id) return await self._preview_url(sandbox, port) # -- provider hooks ------------------------------------------------------ async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any: raise NotImplementedError async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any: raise NotImplementedError async def _read(self, sandbox: Any, path: str) -> str: raise NotImplementedError async def _write(self, sandbox: Any, path: str, content: str) -> None: raise NotImplementedError async def _pause(self, sandbox: Any) -> None: raise NotImplementedError async def _resume(self, sandbox: Any) -> None: raise NotImplementedError async def _destroy(self, sandbox: Any) -> None: raise NotImplementedError async def _preview_url(self, sandbox: Any, port: int) -> str: raise NotImplementedError class DaytonaSandboxAdapter(_RemoteSandboxAdapter): """Daytona Cloud sandboxes via the async SDK. Targets the documented surface: ``AsyncDaytona(DaytonaConfig(...))``, ``client.create()``, ``sandbox.process.exec(...)``, ``sandbox.fs``, ``client.delete(sandbox)``. """ provider_name = "daytona" def __init__( self, *, api_key: str, api_url: str = "", max_output_bytes: int = 200_000 ) -> None: super().__init__(max_output_bytes=max_output_bytes) if not api_key: raise ProviderUnavailableError( "SANDBOX_PROVIDER=daytona نیازمند تنظیم DAYTONA_API_KEY است." ) self._api_key = api_key self._api_url = api_url self._sdk = _load_sdk("daytona") self._preflight() def _preflight(self) -> None: _require(self._sdk, "AsyncDaytona", provider="daytona", where="daytona") _require(self._sdk, "DaytonaConfig", provider="daytona", where="daytona") async def _ensure_client(self) -> Any: if self._client is None: config_kwargs: dict[str, Any] = {"api_key": self._api_key} if self._api_url: config_kwargs["api_url"] = self._api_url config = self._sdk.DaytonaConfig(**config_kwargs) self._client = self._sdk.AsyncDaytona(config) return self._client async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any: # `create` accepts no arguments on the default image; a custom image is # passed positionally as a snapshot/image reference. try: return await client.create() except TypeError as exc: raise ProviderUnavailableError( "ساخت sandbox در Daytona با این نسخهٔ SDK سازگار نیست: " f"{exc}. نسخهٔ پشتیبانی‌شدهٔ daytona را نصب کنید." ) from exc async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any: process = _require(sandbox, "process", provider="daytona", where="sandbox") exec_fn = _require(process, "exec", provider="daytona", where="sandbox.process") response = await exec_fn(command, timeout=timeout) # Daytona reports the exit code separately from the combined output. result = str( _require(response, "result", provider="daytona", where="process response") or "" ) return _DaytonaCommandOutput( exit_code=int(getattr(response, "exit_code", 0) or 0), stdout=result, stderr="", ) async def _read(self, sandbox: Any, path: str) -> str: fs = _require(sandbox, "fs", provider="daytona", where="sandbox") read_fn = _require(fs, "download_file", provider="daytona", where="sandbox.fs") content = await read_fn(path) if isinstance(content, bytes): return content.decode("utf-8", errors="replace") return str(content or "") async def _write(self, sandbox: Any, path: str, content: str) -> None: fs = _require(sandbox, "fs", provider="daytona", where="sandbox") write_fn = _require(fs, "upload_file", provider="daytona", where="sandbox.fs") await write_fn(content.encode("utf-8"), path) async def _pause(self, sandbox: Any) -> None: stop_fn = _require(sandbox, "stop", provider="daytona", where="sandbox") await stop_fn() async def _resume(self, sandbox: Any) -> None: start_fn = _require(sandbox, "start", provider="daytona", where="sandbox") await start_fn() async def _destroy(self, sandbox: Any) -> None: client = await self._ensure_client() delete_fn = _require(client, "delete", provider="daytona", where="AsyncDaytona") await delete_fn(sandbox) async def _preview_url(self, sandbox: Any, port: int) -> str: link_fn = _require(sandbox, "get_preview_link", provider="daytona", where="sandbox") link = await link_fn(port) return str(getattr(link, "url", link)) class E2BSandboxAdapter(_RemoteSandboxAdapter): """E2B sandboxes via the async SDK. Targets the documented surface: ``AsyncSandbox.create(...)``, ``sandbox.commands.run(...)``, ``sandbox.files.read/write``, ``sandbox.kill()``. """ provider_name = "e2b" def __init__(self, *, api_key: str, max_output_bytes: int = 200_000) -> None: super().__init__(max_output_bytes=max_output_bytes) if not api_key: raise ProviderUnavailableError( "SANDBOX_PROVIDER=e2b نیازمند تنظیم E2B_API_KEY است." ) self._api_key = api_key self._sdk = _load_sdk("e2b") self._preflight() def _preflight(self) -> None: _require(self._sdk, "AsyncSandbox", provider="e2b", where="e2b") async def _ensure_client(self) -> Any: # E2B has no long-lived client object; the API key is passed per call. return self._sdk.AsyncSandbox async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any: create_fn = _require(client, "create", provider="e2b", where="AsyncSandbox") return await create_fn(api_key=self._api_key) async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any: commands = _require(sandbox, "commands", provider="e2b", where="sandbox") run_fn = _require(commands, "run", provider="e2b", where="sandbox.commands") result = await run_fn(command, timeout=timeout, envs=env or None) return _E2BCommandOutput( exit_code=int(getattr(result, "exit_code", 0) or 0), stdout=str(getattr(result, "stdout", "") or ""), stderr=str(getattr(result, "stderr", "") or ""), ) async def _read(self, sandbox: Any, path: str) -> str: files = _require(sandbox, "files", provider="e2b", where="sandbox") read_fn = _require(files, "read", provider="e2b", where="sandbox.files") content = await read_fn(path) if isinstance(content, bytes): return content.decode("utf-8", errors="replace") return str(content or "") async def _write(self, sandbox: Any, path: str, content: str) -> None: files = _require(sandbox, "files", provider="e2b", where="sandbox") write_fn = _require(files, "write", provider="e2b", where="sandbox.files") await write_fn(path, content) async def _pause(self, sandbox: Any) -> None: pause_fn = _require(sandbox, "pause", provider="e2b", where="sandbox") await pause_fn() async def _resume(self, sandbox: Any) -> None: # E2B resumes by reconnecting to the sandbox id. connect_fn = _require( self._sdk.AsyncSandbox, "connect", provider="e2b", where="AsyncSandbox" ) await connect_fn(sandbox.sandbox_id, api_key=self._api_key) async def _destroy(self, sandbox: Any) -> None: kill_fn = _require(sandbox, "kill", provider="e2b", where="sandbox") await kill_fn() async def _preview_url(self, sandbox: Any, port: int) -> str: host_fn = _require(sandbox, "get_host", provider="e2b", where="sandbox") return f"https://{await host_fn(port)}" class _DaytonaCommandOutput: __slots__ = ("exit_code", "stdout", "stderr") def __init__(self, *, exit_code: int, stdout: str, stderr: str) -> None: self.exit_code = exit_code self.stdout = stdout self.stderr = stderr class _E2BCommandOutput: __slots__ = ("exit_code", "stdout", "stderr") def __init__(self, *, exit_code: int, stdout: str, stderr: str) -> None: self.exit_code = exit_code self.stdout = stdout self.stderr = stderr def build_remote_sandbox_adapter( provider: str, *, daytona_api_key: str = "", daytona_api_url: str = "", e2b_api_key: str = "" ): """Factory used by the adapter registry.""" normalised = (provider or "").strip().lower() if normalised == "daytona": return DaytonaSandboxAdapter(api_key=daytona_api_key, api_url=daytona_api_url) if normalised == "e2b": return E2BSandboxAdapter(api_key=e2b_api_key) raise ProviderUnavailableError( f"SANDBOX_PROVIDER='{provider}' پشتیبانی نمی‌شود. مقادیر مجاز: mock | daytona | e2b" )