مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
431 lines
18 KiB
Python
431 lines
18 KiB
Python
"""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"
|
|
)
|