Add semi-persistent code-execution sandbox (Jupyter Kernel Gateway runner)

Introduce a pluggable CodeSandbox abstraction with a SandboxManager and a
Jupyter Kernel Gateway backend: the app is a client of a single always-on
runner that executes code in stateful in-process kernels (no child-container
spawning, no docker socket). Sessions bind to a conversation or workflow run
with an agent-selectable TTL clamped by a global cap; execution enforces a
wall-clock deadline with interrupt-on-timeout and capped output, and file
transfer is workspace-contained with size and integrity checks. Adds a
docsgpt-sandbox docker-compose service (resource-capped, read-only, internal
network) plus settings, with a real local-gateway integration test.
This commit is contained in:
Alex committed 2026-06-24 11:26:23 +01:00
1 parent 922ed53a70
commit 3b795dbb83
14 files changed
+1332 -1

No files matched your search

+19
View File
@@ -324,6 +324,25 @@ class Settings(BaseSettings):
SCHEDULE_ONCE_MAX_HORIZON: int = 31_536_000
SCHEDULE_RUN_OUTPUT_RETENTION_DAYS: int = 90
# 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
# 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
SANDBOX_KERNEL_NAME: str = "python3" # kernelspec name to launch per session
SANDBOX_MAX_TTL: int = 1200 # hard cap (s) on agent-selectable keep-alive TTL
SANDBOX_EXEC_TIMEOUT: int = 60 # default wall-clock cap (s) per exec call
SANDBOX_HTTP_TIMEOUT: int = 10 # fixed cap (s) for REST control calls (create/delete/alive/interrupt)
SANDBOX_MAX_OUTPUT_BYTES: int = 8 * 1024 * 1024 # cap on buffered stdout+stderr per exec
SANDBOX_MAX_FILE_BYTES: int = 10 * 1024 * 1024 # cap on get_file size routed through stdout
# Runner container resource caps — consumed by the docsgpt-sandbox compose
# service (deployment/sandbox), not by the app client. cgroup CPU/mem caps
# 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
@field_validator("POSTGRES_URI", mode="before")
@classmethod
def _normalize_postgres_uri_validator(cls, v):
+2 -1
View File
@@ -110,4 +110,5 @@ werkzeug>=3.1.0
yarl==1.23.0
markdownify==1.2.2
tldextract==5.3.1
websockets==16.0
websockets==16.0
websocket-client==1.9.0
+1
View File
@@ -0,0 +1 @@
"""Pluggable code-execution sandbox abstraction for running untrusted LLM code."""
+75
View File
@@ -0,0 +1,75 @@
"""Backend-agnostic code-execution sandbox interface and result types."""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
@dataclass
class DisplayData:
"""A single rich (non-stream) kernel output keyed by MIME bundle."""
data: Dict[str, Any] = field(default_factory=dict)
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class Plot:
"""An image/plot output captured from execution, base64-encoded by format."""
format: str
content_base64: str
@dataclass
class ExecResult:
"""Outcome of one ``exec`` call against a sandbox session."""
status: str = "ok" # "ok" | "error"
stdout: str = ""
stderr: str = ""
exit_code: int = 0
execution_count: Optional[int] = None
error_name: Optional[str] = None
error_value: Optional[str] = None
traceback: List[str] = field(default_factory=list)
results: List[DisplayData] = field(default_factory=list)
display_data: List[DisplayData] = field(default_factory=list)
plots: List[Plot] = field(default_factory=list)
@property
def ok(self) -> bool:
"""True when the execution completed without raising."""
return self.status == "ok"
class CodeSandbox(ABC):
"""Common interface every sandbox backend (Jupyter, Daytona, ...) implements."""
@abstractmethod
def open(self, session_id: str) -> str:
"""Create the underlying runtime for ``session_id`` and return its handle id."""
@abstractmethod
def attach(self, session_id: str) -> str:
"""Reattach to ``session_id``'s runtime; MAY return a cold kernel (state not guaranteed)."""
@abstractmethod
def close(self, session_id: str) -> None:
"""Tear down the runtime bound to ``session_id``, freeing all resources."""
@abstractmethod
def exec(self, session_id: str, code: str, timeout: Optional[float] = None) -> ExecResult:
"""Execute ``code`` in ``session_id``'s stateful runtime and return its result."""
@abstractmethod
def put_file(self, session_id: str, dest_path: str, data: bytes) -> None:
"""Write ``data`` to ``dest_path``; precondition: a relative path contained in the workspace."""
@abstractmethod
def get_file(self, session_id: str, path: str) -> bytes:
"""Read ``path`` from the workspace; precondition: a relative path contained in the workspace."""
@abstractmethod
def list_files(self, session_id: str) -> List[str]:
"""List workspace-relative file paths for ``session_id`` (never escapes the workspace)."""
+454
View File
@@ -0,0 +1,454 @@
"""Jupyter Kernel Gateway sandbox: stateful in-process kernels over REST + WebSocket."""
import base64
import hashlib
import json
import logging
import re
import threading
import time
import uuid
from typing import Dict, List, Optional
from urllib.parse import urlencode, urlparse, urlunparse
import requests
import websocket
from application.sandbox.base import (
CodeSandbox,
DisplayData,
ExecResult,
Plot,
)
logger = logging.getLogger(__name__)
# Per-session workspace root inside the runner container. The kernel sets its
# cwd here so relative paths from LLM code and file in/out share one directory.
_WORKSPACE_ROOT = "/tmp/docsgpt-sandbox"
# Marker the get_file helper prints around the base64 payload so we can extract
# it from interleaved stdout without a contents API (the gateway has none).
_FILE_BEGIN = "<<<DOCSGPT_FILE_BEGIN>>>"
_FILE_END = "<<<DOCSGPT_FILE_END>>>"
# Session ids become filesystem path components; allow only safe characters.
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
# Kernel-side helper that resolves a workspace-relative path and rejects any
# absolute path or one that escapes the per-session workspace (path traversal).
_CONTAINMENT_SNIPPET = (
"def _resolve(_base, _rel):\n"
" import os as _os\n"
" if _os.path.isabs(_rel):\n"
" raise ValueError('absolute paths are not allowed')\n"
" _root = _os.path.realpath(_base)\n"
" _rp = _os.path.realpath(_os.path.join(_root, _rel))\n"
" if _rp != _root and not _rp.startswith(_root + _os.sep):\n"
" raise ValueError('path escapes the session workspace')\n"
" return _rp\n"
)
class _Kernel:
"""Tracks one gateway kernel plus the per-session workspace it executes in."""
def __init__(self, kernel_id: str, workspace: str) -> None:
self.kernel_id = kernel_id
self.workspace = workspace
self.initialized = False
class JupyterKernelGatewaySandbox(CodeSandbox):
"""Drives one always-on Jupyter Kernel Gateway, one stateful kernel per session."""
def __init__(
self,
gateway_url: str,
auth_token: Optional[str] = None,
kernel_name: str = "python3",
default_timeout: float = 60.0,
http_timeout: float = 10.0,
max_output_bytes: int = 8 * 1024 * 1024,
max_file_bytes: int = 10 * 1024 * 1024,
) -> None:
"""Configure the client; no kernel is created until ``open`` is called."""
self._base_url = gateway_url.rstrip("/")
self._auth_token = auth_token
self._kernel_name = kernel_name
self._default_timeout = default_timeout
self._http_timeout = http_timeout
self._max_output_bytes = max_output_bytes
self._max_file_bytes = max_file_bytes
self._kernels: Dict[str, _Kernel] = {}
self._lock = threading.Lock()
# -- HTTP helpers ----------------------------------------------------
def _headers(self) -> Dict[str, str]:
headers = {"Content-Type": "application/json"}
if self._auth_token:
headers["Authorization"] = f"token {self._auth_token}"
return headers
def _ws_url(self, kernel_id: str) -> str:
parsed = urlparse(self._base_url)
scheme = "wss" if parsed.scheme == "https" else "ws"
path = f"/api/kernels/{kernel_id}/channels"
query = urlencode({"token": self._auth_token}) if self._auth_token else ""
return urlunparse((scheme, parsed.netloc, path, "", query, ""))
def _get_kernel(self, session_id: str) -> _Kernel:
with self._lock:
kernel = self._kernels.get(session_id)
if kernel is None:
raise KeyError(f"No sandbox session open for {session_id!r}")
return kernel
@staticmethod
def _validate_session_id(session_id: str) -> None:
"""Reject session ids that would not be a safe filesystem path component."""
if not _SESSION_ID_RE.match(session_id):
raise ValueError(f"Invalid session id {session_id!r}: expected [A-Za-z0-9_-]+")
# -- Lifecycle -------------------------------------------------------
def open(self, session_id: str) -> str:
"""Start a fresh kernel for ``session_id`` and prime its workspace cwd."""
self._validate_session_id(session_id)
with self._lock:
existing = self._kernels.get(session_id)
if existing is not None:
return existing.kernel_id
resp = requests.post(
f"{self._base_url}/api/kernels",
headers=self._headers(),
data=json.dumps({"name": self._kernel_name}),
timeout=self._http_timeout,
)
resp.raise_for_status()
kernel_id = resp.json()["id"]
workspace = f"{_WORKSPACE_ROOT}/{session_id}"
kernel = _Kernel(kernel_id, workspace)
with self._lock:
self._kernels[session_id] = kernel
self._prime(kernel)
return kernel_id
def attach(self, session_id: str) -> str:
"""Reattach to a still-running kernel for ``session_id``; open a cold one if gone."""
self._validate_session_id(session_id)
with self._lock:
existing = self._kernels.get(session_id)
if existing is not None and self._kernel_alive(existing.kernel_id):
return existing.kernel_id
if existing is not None:
with self._lock:
self._kernels.pop(session_id, None)
logger.warning("Re-attaching session %s to a cold kernel; previous state is lost", session_id)
return self.open(session_id)
def close(self, session_id: str) -> None:
"""Delete the gateway kernel for ``session_id`` and drop it from the registry."""
with self._lock:
kernel = self._kernels.pop(session_id, None)
if kernel is None:
return
try:
requests.delete(
f"{self._base_url}/api/kernels/{kernel.kernel_id}",
headers=self._headers(),
timeout=self._http_timeout,
)
except requests.RequestException as exc: # teardown is best-effort
logger.warning("Failed to delete kernel %s: %s", kernel.kernel_id, exc)
def _kernel_alive(self, kernel_id: str) -> bool:
try:
resp = requests.get(
f"{self._base_url}/api/kernels/{kernel_id}",
headers=self._headers(),
timeout=self._http_timeout,
)
return resp.status_code == 200
except requests.RequestException:
return False
def _interrupt(self, kernel_id: str) -> None:
"""Best-effort interrupt so a timed-out/runaway kernel becomes reusable."""
try:
requests.post(
f"{self._base_url}/api/kernels/{kernel_id}/interrupt",
headers=self._headers(),
timeout=self._http_timeout,
)
except requests.RequestException as exc:
logger.warning("Failed to interrupt kernel %s: %s", kernel_id, exc)
def _interrupt_and_drain(self, ws: websocket.WebSocket, msg_id: str, kernel_id: str) -> None:
"""Interrupt the kernel then drain frames until it idles, leaving the session reusable."""
self._interrupt(kernel_id)
drain_deadline = time.monotonic() + self._http_timeout
while time.monotonic() < drain_deadline:
try:
ws.settimeout(max(0.05, drain_deadline - time.monotonic()))
raw = ws.recv()
except (websocket.WebSocketTimeoutException, websocket.WebSocketConnectionClosedException):
return
if not raw:
continue
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
if msg.get("parent_header", {}).get("msg_id") != msg_id:
continue
msg_type = msg.get("msg_type") or msg.get("header", {}).get("msg_type")
if msg_type == "status" and msg.get("content", {}).get("execution_state") == "idle":
return
def _prime(self, kernel: _Kernel) -> None:
"""Create the per-session workspace dir and chdir the kernel into it."""
setup = (
"import os as _os\n"
f"_os.makedirs({kernel.workspace!r}, exist_ok=True)\n"
f"_os.chdir({kernel.workspace!r})\n"
)
result = self._run(kernel, setup, self._default_timeout)
if not result.ok:
raise RuntimeError(f"Sandbox workspace setup failed: {result.error_value}")
kernel.initialized = True
# -- Execution -------------------------------------------------------
def exec(self, session_id: str, code: str, timeout: Optional[float] = None) -> ExecResult:
"""Run ``code`` in the session's persistent kernel; state carries across calls."""
kernel = self._get_kernel(session_id)
return self._run(kernel, code, timeout or self._default_timeout)
def _run(self, kernel: _Kernel, code: str, timeout: float) -> ExecResult:
"""Execute one ``execute_request`` over the WS channel and assemble the reply."""
try:
ws = websocket.create_connection(
self._ws_url(kernel.kernel_id),
timeout=timeout,
header=self._ws_headers(),
)
except Exception as exc: # noqa: BLE001 - connect failure -> error result, never raise
return _error_result(type(exc).__name__, str(exc) or "failed to open kernel channel")
try:
msg_id = uuid.uuid4().hex
ws.send(json.dumps(self._execute_request(msg_id, code)))
return self._collect(ws, msg_id, timeout, kernel.kernel_id)
finally:
try:
ws.close()
except Exception: # noqa: BLE001 - closing a socket must not mask results
pass
def _ws_headers(self) -> List[str]:
if self._auth_token:
return [f"Authorization: token {self._auth_token}"]
return []
@staticmethod
def _execute_request(msg_id: str, code: str) -> dict:
"""Build a Jupyter ``execute_request`` wire message for the shell channel."""
return {
"header": {
"msg_id": msg_id,
"username": "docsgpt",
"session": uuid.uuid4().hex,
"msg_type": "execute_request",
"version": "5.3",
},
"parent_header": {},
"metadata": {},
"content": {
"code": code,
"silent": False,
"store_history": True,
"user_expressions": {},
"allow_stdin": False,
"stop_on_error": True,
},
"channel": "shell",
}
def _collect(self, ws: websocket.WebSocket, msg_id: str, timeout: float, kernel_id: str) -> ExecResult:
"""Read iopub/shell frames until ``execute_reply``/idle, a wall-clock deadline, or a closed socket."""
result = ExecResult()
stdout_parts: List[str] = []
stderr_parts: List[str] = []
buffered = 0
truncated = False
reply_seen = False
idle_seen = False
deadline = time.monotonic() + timeout
while not (reply_seen and idle_seen):
now = time.monotonic()
remaining = deadline - now
if remaining <= 0:
self._fail(result, "TimeoutError", f"execution exceeded {timeout}s")
self._interrupt_and_drain(ws, msg_id, kernel_id)
break
try:
ws.settimeout(remaining)
raw = ws.recv()
except websocket.WebSocketTimeoutException:
self._fail(result, "TimeoutError", f"execution exceeded {timeout}s")
self._interrupt_and_drain(ws, msg_id, kernel_id)
break
except websocket.WebSocketConnectionClosedException:
self._fail(result, "KernelDiedError", "kernel channel closed before completion")
break
if not raw:
continue
try:
msg = json.loads(raw)
except json.JSONDecodeError as exc:
self._fail(result, "ProtocolError", f"malformed kernel frame: {exc}")
break
if msg.get("parent_header", {}).get("msg_id") != msg_id:
continue
msg_type = msg.get("msg_type") or msg.get("header", {}).get("msg_type")
content = msg.get("content", {})
if msg_type == "stream":
if not truncated:
text = content.get("text", "")
buffered += len(text)
if buffered > self._max_output_bytes:
truncated = True
self._interrupt_and_drain(ws, msg_id, kernel_id) # runaway output: stop and drain
break
elif content.get("name") == "stderr":
stderr_parts.append(text)
else:
stdout_parts.append(text)
elif msg_type in ("execute_result", "display_data"):
self._capture_rich(result, msg_type, content)
elif msg_type == "error":
result.status = "error"
result.exit_code = 1
result.error_name = content.get("ename")
result.error_value = content.get("evalue")
result.traceback = content.get("traceback", [])
elif msg_type == "execute_reply":
reply_seen = True
result.execution_count = content.get("execution_count")
if content.get("status") == "error":
result.status = "error"
result.exit_code = result.exit_code or 1
result.error_name = result.error_name or content.get("ename")
result.error_value = result.error_value or content.get("evalue")
if not result.traceback:
result.traceback = content.get("traceback", [])
elif msg_type == "status":
if content.get("execution_state") == "idle":
idle_seen = True
if truncated:
stderr_parts.append(f"\n[output truncated at {self._max_output_bytes} bytes]")
result.stdout = "".join(stdout_parts)
result.stderr = "".join(stderr_parts)
return result
@staticmethod
def _fail(result: ExecResult, name: str, value: str) -> None:
"""Mark ``result`` as a failed exec with the given error name/value."""
result.status = "error"
result.error_name = name
result.error_value = value
result.exit_code = -1
@staticmethod
def _capture_rich(result: ExecResult, msg_type: str, content: dict) -> None:
"""Sort a rich output into results/display_data and pull out any image plots."""
data = content.get("data", {}) or {}
metadata = content.get("metadata", {}) or {}
bundle = DisplayData(data=data, metadata=metadata)
if msg_type == "execute_result":
result.results.append(bundle)
else:
result.display_data.append(bundle)
for mime, payload in data.items():
if mime.startswith("image/"):
result.plots.append(Plot(format=mime.split("/", 1)[1], content_base64=payload))
# -- File transfer ---------------------------------------------------
def put_file(self, session_id: str, dest_path: str, data: bytes) -> None:
"""Decode ``data`` inside the kernel and write it under the session workspace."""
kernel = self._get_kernel(session_id)
encoded = base64.b64encode(data).decode("ascii")
code = (
"import base64 as _b64, os as _os\n"
+ _CONTAINMENT_SNIPPET
+ f"_p = _resolve({kernel.workspace!r}, {dest_path!r})\n"
"_os.makedirs(_os.path.dirname(_p) or '.', exist_ok=True)\n"
f"_f = open(_p, 'wb'); _f.write(_b64.b64decode({encoded!r})); _f.close()\n"
)
result = self._run(kernel, code, self._default_timeout)
if not result.ok:
raise IOError(f"put_file failed: {result.error_value}")
def get_file(self, session_id: str, path: str) -> bytes:
"""Read ``path`` inside the kernel and stream its base64 (with a length tag) over stdout."""
kernel = self._get_kernel(session_id)
code = (
"import base64 as _b64, hashlib as _hl, os as _os\n"
+ _CONTAINMENT_SNIPPET
+ f"_p = _resolve({kernel.workspace!r}, {path!r})\n"
f"_sz = _os.path.getsize(_p)\n"
f"if _sz > {self._max_file_bytes}:\n"
f" raise ValueError('file too large: %d > {self._max_file_bytes} bytes' % _sz)\n"
"_d = open(_p, 'rb').read()\n"
"_h = _hl.sha256(_d).hexdigest()\n"
f"print({_FILE_BEGIN!r} + str(len(_d)) + ':' + _h + ':'"
f" + _b64.b64encode(_d).decode('ascii') + {_FILE_END!r})\n"
)
result = self._run(kernel, code, self._default_timeout)
if not result.ok:
raise IOError(f"get_file failed: {result.error_value}")
out = result.stdout
start = out.find(_FILE_BEGIN)
end = out.find(_FILE_END)
if start == -1 or end == -1:
raise IOError(f"get_file produced no payload for {path!r}")
payload = out[start + len(_FILE_BEGIN):end]
expected_len_s, expected_sha, encoded = payload.split(":", 2)
decoded = base64.b64decode(encoded)
if len(decoded) != int(expected_len_s) or hashlib.sha256(decoded).hexdigest() != expected_sha:
raise IOError(f"get_file integrity check failed for {path!r} (payload truncated)")
return decoded
def list_files(self, session_id: str) -> List[str]:
"""Walk the session workspace inside the kernel and return relative paths."""
kernel = self._get_kernel(session_id)
code = (
"import os as _os, json as _json\n"
f"_root = {kernel.workspace!r}\n"
"_out = []\n"
"for _dp, _dn, _fn in _os.walk(_root):\n"
" for _name in _fn:\n"
" _out.append(_os.path.relpath(_os.path.join(_dp, _name), _root))\n"
f"print({_FILE_BEGIN!r} + _json.dumps(_out) + {_FILE_END!r})\n"
)
result = self._run(kernel, code, self._default_timeout)
if not result.ok:
raise IOError(f"list_files failed: {result.error_value}")
out = result.stdout
start = out.find(_FILE_BEGIN)
end = out.find(_FILE_END)
if start == -1 or end == -1:
return []
return json.loads(out[start + len(_FILE_BEGIN):end])
def _error_result(name: str, value: str) -> ExecResult:
"""Build a failed ExecResult carrying the given error name/value."""
return ExecResult(status="error", error_name=name, error_value=value, exit_code=-1)
+135
View File
@@ -0,0 +1,135 @@
"""Process-wide registry binding session ids to sandbox handles with idle expiry."""
import threading
import time
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from application.sandbox.base import CodeSandbox, ExecResult
@dataclass
class _Session:
"""Bookkeeping for one bound sandbox session: its TTL and access timestamps."""
session_id: str
ttl: float
created_at: float
last_access: float = field(default=0.0)
def is_expired(self, now: float) -> bool:
"""True when the session has been idle longer than its (clamped) TTL."""
return (now - self.last_access) > self.ttl
class SandboxManager:
"""Binds session ids to a shared backend, clamps TTLs, and reaps idle sessions."""
def __init__(self, backend: CodeSandbox, max_ttl: float, default_ttl: Optional[float] = None) -> None:
"""Wrap ``backend`` with a registry clamped to ``max_ttl`` seconds per session."""
self._backend = backend
self._max_ttl = max_ttl
self._default_ttl = default_ttl if default_ttl is not None else max_ttl
self._sessions: Dict[str, _Session] = {}
self._lock = threading.Lock()
def _clamp_ttl(self, ttl: Optional[float]) -> float:
"""Return an agent-requested TTL bounded to (0, ``max_ttl``]."""
if ttl is None:
ttl = self._default_ttl
if ttl <= 0:
return self._default_ttl
return min(ttl, self._max_ttl)
def open(self, session_id: str, ttl: Optional[float] = None) -> str:
"""Open (or reuse) the sandbox for ``session_id`` with a clamped TTL."""
self.reap_expired()
with self._lock:
session = self._sessions.get(session_id)
now = time.monotonic()
if session is not None:
session.last_access = now
handle = self._backend.attach(session_id)
return handle
handle = self._backend.open(session_id)
self._sessions[session_id] = _Session(
session_id=session_id,
ttl=self._clamp_ttl(ttl),
created_at=now,
last_access=now,
)
return handle
def attach(self, session_id: str) -> str:
"""Reattach to an existing session, refreshing its idle clock."""
with self._lock:
session = self._sessions.get(session_id)
if session is None:
raise KeyError(f"No sandbox session bound for {session_id!r}")
session.last_access = time.monotonic()
return self._backend.attach(session_id)
def exec(self, session_id: str, code: str, timeout: Optional[float] = None) -> ExecResult:
"""Execute ``code`` in the bound session, touching its idle clock first."""
with self._lock:
session = self._sessions.get(session_id)
if session is None:
raise KeyError(f"No sandbox session bound for {session_id!r}")
session.last_access = time.monotonic()
return self._backend.exec(session_id, code, timeout)
def put_file(self, session_id: str, dest_path: str, data: bytes) -> None:
"""Write ``data`` into the bound session's workspace."""
self._touch(session_id)
self._backend.put_file(session_id, dest_path, data)
def get_file(self, session_id: str, path: str) -> bytes:
"""Read ``path`` from the bound session's workspace."""
self._touch(session_id)
return self._backend.get_file(session_id, path)
def list_files(self, session_id: str) -> List[str]:
"""List files in the bound session's workspace."""
self._touch(session_id)
return self._backend.list_files(session_id)
def close(self, session_id: str) -> None:
"""Tear down the backend runtime and drop the session from the registry."""
with self._lock:
self._sessions.pop(session_id, None)
self._backend.close(session_id)
def has_session(self, session_id: str) -> bool:
"""True when ``session_id`` is currently bound in the registry."""
with self._lock:
return session_id in self._sessions
def ttl_for(self, session_id: str) -> Optional[float]:
"""Return the clamped TTL bound to ``session_id``, or None if unbound."""
with self._lock:
session = self._sessions.get(session_id)
return session.ttl if session else None
def _touch(self, session_id: str) -> None:
with self._lock:
session = self._sessions.get(session_id)
if session is None:
raise KeyError(f"No sandbox session bound for {session_id!r}")
session.last_access = time.monotonic()
def reap_expired(self) -> List[str]:
"""Close sessions idle past their TTL and return the reaped session ids.
Extension point: a later hardening slice runs this on a background
reaper and, before close, flushes workspace files to the artifact
store (persist-on-reap) so a fresh kernel can re-mount them by
reference on next access. For now it is a plain idle close.
"""
now = time.monotonic()
with self._lock:
expired = [sid for sid, s in self._sessions.items() if s.is_expired(now)]
for sid in expired:
self._sessions.pop(sid, None)
for sid in expired:
self._backend.close(sid)
return expired
+56
View File
@@ -0,0 +1,56 @@
"""Factory + process-wide singleton selecting a sandbox backend from settings."""
from typing import Callable, Dict
from application.core.settings import settings
from application.sandbox.base import CodeSandbox
from application.sandbox.jupyter_gateway import JupyterKernelGatewaySandbox
from application.sandbox.manager import SandboxManager
def _make_jupyter() -> CodeSandbox:
"""Build the Jupyter Kernel Gateway backend from ``SANDBOX_*`` settings."""
return JupyterKernelGatewaySandbox(
gateway_url=settings.SANDBOX_GATEWAY_URL,
auth_token=settings.SANDBOX_GATEWAY_AUTH_TOKEN,
kernel_name=settings.SANDBOX_KERNEL_NAME,
default_timeout=float(settings.SANDBOX_EXEC_TIMEOUT),
http_timeout=float(settings.SANDBOX_HTTP_TIMEOUT),
max_output_bytes=int(settings.SANDBOX_MAX_OUTPUT_BYTES),
max_file_bytes=int(settings.SANDBOX_MAX_FILE_BYTES),
)
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,
}
_instance = None
@classmethod
def get_manager(cls) -> SandboxManager:
"""Return the process-wide ``SandboxManager``, building it on first use."""
if cls._instance is None:
backend = cls.create_backend(getattr(settings, "SANDBOX_BACKEND", "jupyter"))
cls._instance = SandboxManager(
backend=backend,
max_ttl=float(settings.SANDBOX_MAX_TTL),
)
return cls._instance
@classmethod
def create_backend(cls, type_name: str) -> CodeSandbox:
"""Instantiate the backend registered under ``type_name`` (case-insensitive)."""
factory = cls.backends.get(type_name.lower())
if not factory:
raise ValueError(f"No sandbox backend found for type {type_name}")
return factory()
@classmethod
def reset(cls) -> None:
"""Drop the cached singleton (test/teardown hook)."""
cls._instance = None
+33
View File
@@ -24,6 +24,8 @@ services:
- CELERY_RESULT_BACKEND=redis://redis:6379/1
- CACHE_REDIS_URL=redis://redis:6379/2
- POSTGRES_URI=postgresql://docsgpt:docsgpt@postgres:5432/docsgpt
# Code-execution runner reached over HTTP + WebSocket (no docker socket).
- SANDBOX_GATEWAY_URL=http://docsgpt-sandbox:8888
ports:
- "7091:7091"
volumes:
@@ -49,6 +51,7 @@ services:
- API_URL=http://backend:7091
- CACHE_REDIS_URL=redis://redis:6379/2
- POSTGRES_URI=postgresql://docsgpt:docsgpt@postgres:5432/docsgpt
- SANDBOX_GATEWAY_URL=http://docsgpt-sandbox:8888
volumes:
- ../application/indexes:/app/indexes
- ../application/inputs:/app/inputs
@@ -59,6 +62,30 @@ services:
postgres:
condition: service_healthy
# Always-on code-execution runner (Jupyter Kernel Gateway). Sessions are
# in-process kernels, never child containers; the Docker socket is NOT
# mounted. On an internal-only network — no host port is published, so the
# runner is reachable only from backend/worker, not from the host/internet.
# Egress/SSRF blocks, the gVisor `runsc` runtime, and seccomp profile come in
# the hardening slice.
docsgpt-sandbox:
build: ./sandbox
mem_limit: ${SANDBOX_MEMORY:-1g}
cpus: ${SANDBOX_CPUS:-1.0}
pids_limit: 256
read_only: true
environment:
# Keep Jupyter's runtime/connection files on the writable tmpfs.
- JUPYTER_RUNTIME_DIR=/tmp/jupyter-runtime
- JUPYTER_DATA_DIR=/tmp/jupyter-data
tmpfs:
# Per-session workspaces (/tmp/docsgpt-sandbox/<session_id>) and Jupyter
# runtime files live on tmpfs; the root FS is read-only everywhere else.
- /tmp
networks:
- sandbox-net
- default
redis:
image: redis:6-alpine
ports:
@@ -80,6 +107,12 @@ services:
timeout: 5s
retries: 10
networks:
# Internal-only network for the sandbox runner (no external egress route via
# this network; the runner still reaches the internet via the default bridge).
sandbox-net:
internal: true
volumes:
postgres_data:
+48
View File
@@ -0,0 +1,48 @@
# docsgpt-sandbox: the always-on code-execution runner.
#
# One container runs a Jupyter Kernel Gateway; each sandbox session is an
# in-process kernel (a child process), NOT a child container. This image does
# NOT mount the Docker socket and never spawns containers — that avoids the
# host-root risk of Docker-in-Docker and is the core security win.
#
# The app (backend/worker) is the CLIENT and reaches this service over
# HTTP + WebSocket via SANDBOX_GATEWAY_URL.
#
# Pre-baked doc libs are all permissive (MIT/BSD/Apache) for speed/reliability;
# runtime `pip install` still works because egress is open. PyMuPDF and any
# other AGPL lib are intentionally excluded; Docling is deferred to its own slice.
#
# HARDENING (separate slice — NOT done here): run under the gVisor `runsc`
# runtime, add network-layer SSRF blocks (drop RFC1918 / link-local /
# 169.254.169.254), seccomp profile, read-only root FS + quota'd scratch dir,
# and cgroup CPU/mem/PID caps wired from SANDBOX_MEMORY / SANDBOX_CPUS.
FROM python:3.12-slim
# Non-root user for the runner (untrusted code runs as this UID).
RUN useradd --create-home --uid 10001 sandbox
# Exact pins for reproducible builds and no license drift (all MIT/BSD/Apache).
RUN pip install --no-cache-dir \
jupyter-kernel-gateway==3.0.1 \
ipykernel==6.29.5 \
python-pptx==1.0.2 \
python-docx==1.1.2 \
openpyxl==3.1.5 \
reportlab==4.2.5 \
pandas==2.2.3 \
matplotlib==3.9.2
USER sandbox
WORKDIR /home/sandbox
EXPOSE 8888
# ip=0.0.0.0 so the backend can reach it over the internal compose network.
# allow_origin is intentionally NOT set to "*" — publishing port 8888 requires
# setting SANDBOX_GATEWAY_AUTH_TOKEN (see README). limit_rate=False raises the
# iopub data-rate cap so large get_file base64 payloads are not truncated; the
# get_file integrity check still guards against truncation if this is ever off.
CMD ["jupyter", "kernelgateway", \
"--KernelGatewayApp.ip=0.0.0.0", \
"--KernelGatewayApp.port=8888", \
"--ZMQChannelsWebsocketConnection.limit_rate=False"]
+50
View File
@@ -0,0 +1,50 @@
# docsgpt-sandbox runner
Always-on Jupyter Kernel Gateway that executes sandboxed LLM code. The DocsGPT
backend/worker is the **client** and connects over HTTP + WebSocket via
`SANDBOX_GATEWAY_URL`. Each session is an **in-process kernel** (child process),
never a child container; the Docker socket is **not** mounted.
## Run standalone for dev
Build and run the runner on its own, then point the app at it:
```bash
docker build -t docsgpt-sandbox deployment/sandbox
docker run --rm -p 8888:8888 docsgpt-sandbox
# in the app's .env: SANDBOX_GATEWAY_URL=http://localhost:8888
```
Without Docker (matches the test harness) you can run the gateway directly from
a venv that has `jupyter-kernel-gateway` installed:
```bash
jupyter kernelgateway --KernelGatewayApp.ip=0.0.0.0 --KernelGatewayApp.port=8888 \
--ZMQChannelsWebsocketConnection.limit_rate=False
```
`--ZMQChannelsWebsocketConnection.limit_rate=False` raises the iopub data-rate
limit so large `get_file` base64 payloads aren't silently truncated. (On older
gateways the trait may live elsewhere; the client's `get_file` integrity check
catches any truncation regardless.)
## Exposing the port requires auth
The image does **not** set `--KernelGatewayApp.allow_origin=*`. If you publish
port 8888 (e.g. `docker run -p 8888:8888`), set `SANDBOX_GATEWAY_AUTH_TOKEN`
and launch the gateway with a matching `--KernelGatewayApp.auth_token` so the
runner is not an open arbitrary-code-execution endpoint. In compose the runner
stays on the internal-only network with no published port, so no token is
required there.
## In docker-compose
The `docsgpt-sandbox` service is defined in `deployment/docker-compose.yaml` on
an internal-only network. The backend and worker reach it at
`http://docsgpt-sandbox:8888`.
## Hardening (separate slice — not in this image)
Egress/SSRF blocks (drop RFC1918, link-local, `169.254.169.254`), the gVisor
`runsc` runtime, seccomp, read-only root FS, and cgroup CPU/mem/PID caps wired
from `SANDBOX_MEMORY` / `SANDBOX_CPUS` land in the hardening slice.
+6
View File
@@ -5,3 +5,9 @@ coverage>=7.4.0
pytest-postgresql>=6.0.0
psycopg[binary]>=3.1
cryptography>=46.0.0
# Runner engine for the sandbox integration test: tests launch a local
# `jupyter kernelgateway` process (no Docker). Not an app runtime dependency.
# ipykernel provides the python3 kernel the gateway launches per session.
jupyter-kernel-gateway>=2.5.0
ipykernel>=6.0.0
websocket-client>=1.6.0
View File
Whitespace-only changes.
@@ -0,0 +1,292 @@
"""End-to-end test against a locally launched Jupyter Kernel Gateway subprocess.
Launches a real `jupyter kernelgateway` on an ephemeral port (no Docker — the
Docker credential helper hangs on this machine), points the backend at it, and
exercises the full session lifecycle. Skips gracefully if the gateway binary or
the websocket-client library is unavailable.
"""
import shutil
import socket
import subprocess
import time
import pytest
requests = pytest.importorskip("requests")
pytest.importorskip("websocket") # websocket-client
from application.sandbox.jupyter_gateway import JupyterKernelGatewaySandbox # noqa: E402
_GATEWAY_BIN = shutil.which("jupyter-kernelgateway") or shutil.which("jupyter")
pytestmark = pytest.mark.skipif(
_GATEWAY_BIN is None,
reason="jupyter kernel gateway not installed (pip install jupyter-kernel-gateway)",
)
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def _gateway_cmd(port: int) -> list:
if _GATEWAY_BIN.endswith("jupyter-kernelgateway"):
base = [_GATEWAY_BIN]
else:
base = [_GATEWAY_BIN, "kernelgateway"]
return base + [
"--KernelGatewayApp.ip=127.0.0.1",
f"--KernelGatewayApp.port={port}",
# Raise the iopub data-rate limit so large get_file payloads aren't truncated.
"--ZMQChannelsWebsocketConnection.limit_rate=False",
]
@pytest.fixture(scope="module")
def gateway_url():
port = _free_port()
proc = subprocess.Popen(
_gateway_cmd(port),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
url = f"http://127.0.0.1:{port}"
deadline = time.time() + 30
ready = False
try:
while time.time() < deadline:
if proc.poll() is not None:
pytest.skip("jupyter kernelgateway process exited during startup")
try:
resp = requests.get(f"{url}/api", timeout=1)
if resp.status_code == 200:
ready = True
break
except requests.RequestException:
time.sleep(0.3)
if not ready:
pytest.skip("jupyter kernelgateway did not become ready in time")
yield url
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture()
def sandbox(gateway_url):
sb = JupyterKernelGatewaySandbox(gateway_url=gateway_url, default_timeout=30.0)
yield sb
def test_open_and_close_lifecycle(sandbox):
session = "conv-lifecycle"
kernel_id = sandbox.open(session)
assert kernel_id
resp = requests.get(f"{sandbox._base_url}/api/kernels/{kernel_id}", timeout=5)
assert resp.status_code == 200
sandbox.close(session)
resp = requests.get(f"{sandbox._base_url}/api/kernels/{kernel_id}", timeout=5)
assert resp.status_code == 404 # kernel torn down
def test_stateful_exec_carries_variables(sandbox):
session = "conv-state"
sandbox.open(session)
try:
first = sandbox.exec(session, "x = 1")
assert first.ok, first.error_value
second = sandbox.exec(session, "print(x + 41)")
assert second.ok, second.error_value
assert second.stdout.strip() == "42"
assert second.execution_count is not None
finally:
sandbox.close(session)
def test_stdout_capture(sandbox):
session = "conv-stdout"
sandbox.open(session)
try:
res = sandbox.exec(session, "print('hello sandbox')")
assert res.ok
assert "hello sandbox" in res.stdout
finally:
sandbox.close(session)
def test_error_capture_sets_failure_status(sandbox):
session = "conv-error"
sandbox.open(session)
try:
res = sandbox.exec(session, "raise ValueError('boom')")
assert not res.ok
assert res.status == "error"
assert res.error_name == "ValueError"
assert "boom" in (res.error_value or "")
assert res.traceback
finally:
sandbox.close(session)
def test_put_and_get_file_roundtrip_exact_bytes(sandbox):
session = "conv-files"
sandbox.open(session)
try:
payload = bytes(range(256)) * 4 # 1 KiB of every byte value
sandbox.put_file(session, "data/blob.bin", payload)
assert "data/blob.bin" in sandbox.list_files(session)
fetched = sandbox.get_file(session, "data/blob.bin")
assert fetched == payload
finally:
sandbox.close(session)
def test_file_written_by_code_is_readable(sandbox):
session = "conv-codefile"
sandbox.open(session)
try:
res = sandbox.exec(session, "open('out.txt', 'w').write('generated')")
assert res.ok, res.error_value
assert sandbox.get_file(session, "out.txt") == b"generated"
finally:
sandbox.close(session)
def test_attach_reuses_running_kernel(sandbox):
session = "conv-attach"
first_id = sandbox.open(session)
try:
sandbox.exec(session, "y = 7")
second_id = sandbox.attach(session)
assert second_id == first_id
res = sandbox.exec(session, "print(y)")
assert res.stdout.strip() == "7"
finally:
sandbox.close(session)
def test_exec_timeout_reports_error(sandbox):
session = "conv-timeout"
sandbox.open(session)
try:
res = sandbox.exec(session, "import time; time.sleep(5)", timeout=1.0)
assert not res.ok
assert res.error_name == "TimeoutError"
finally:
sandbox.close(session)
def test_kernel_killed_mid_exec_returns_error(sandbox):
session = "conv-killed"
kernel_id = sandbox.open(session)
try:
import threading
def _kill():
time.sleep(0.5)
requests.delete(f"{sandbox._base_url}/api/kernels/{kernel_id}", timeout=5)
killer = threading.Thread(target=_kill)
killer.start()
res = sandbox.exec(session, "import time\nfor _ in range(50): time.sleep(0.2)", timeout=20.0)
killer.join()
assert not res.ok # error result, no raise / no hang
assert res.exit_code == -1
finally:
sandbox.close(session)
def test_runaway_loop_times_out_and_session_stays_reusable(sandbox):
session = "conv-runaway"
sandbox.open(session)
try:
start = time.monotonic()
res = sandbox.exec(session, "while True:\n print('x')", timeout=2.0)
elapsed = time.monotonic() - start
assert not res.ok
assert res.error_name == "TimeoutError"
assert elapsed < 2.0 + 8.0 # returns within timeout + interrupt slack
# Interrupt should have freed the kernel: a fresh exec on the same session works.
again = sandbox.exec(session, "print(2 + 2)", timeout=10.0)
assert again.ok, again.error_value
assert again.stdout.strip() == "4"
finally:
sandbox.close(session)
def test_output_cap_truncates_runaway_output(gateway_url):
sb = JupyterKernelGatewaySandbox(gateway_url=gateway_url, default_timeout=30.0, max_output_bytes=50_000)
session = "conv-bigout"
sb.open(session)
try:
res = sb.exec(session, "for _ in range(100000): print('A' * 1000)", timeout=20.0)
assert "truncated" in res.stderr
assert len(res.stdout) <= 60_000 # capped well under the unbounded ~100MB
again = sb.exec(session, "print('alive')", timeout=10.0)
assert again.ok and "alive" in again.stdout
finally:
sb.close(session)
def test_put_file_rejects_path_traversal(sandbox):
session = "conv-trav-put"
sandbox.open(session)
try:
with pytest.raises(IOError):
sandbox.put_file(session, "../escape.txt", b"nope")
with pytest.raises(IOError):
sandbox.put_file(session, "/etc/escape.txt", b"nope")
# The escape target must not exist (no file written outside the workspace).
probe = sandbox.exec(session, "import os; print(os.path.exists('/etc/escape.txt'))")
assert probe.stdout.strip() == "False"
finally:
sandbox.close(session)
def test_get_file_rejects_path_traversal(sandbox):
session = "conv-trav-get"
sandbox.open(session)
try:
with pytest.raises(IOError):
sandbox.get_file(session, "../../etc/passwd")
with pytest.raises(IOError):
sandbox.get_file(session, "/etc/passwd")
finally:
sandbox.close(session)
def test_get_file_rejects_oversized_file(gateway_url):
sb = JupyterKernelGatewaySandbox(gateway_url=gateway_url, default_timeout=30.0, max_file_bytes=1024)
session = "conv-toobig"
sb.open(session)
try:
sb.put_file(session, "big.bin", b"x" * 4096) # 4 KiB > 1 KiB cap
with pytest.raises(IOError):
sb.get_file(session, "big.bin")
finally:
sb.close(session)
def test_get_file_integrity_roundtrip_large(gateway_url):
sb = JupyterKernelGatewaySandbox(gateway_url=gateway_url, default_timeout=60.0)
session = "conv-large"
sb.open(session)
try:
payload = bytes(range(256)) * 8192 # 2 MiB, exercises the iopub path + integrity check
sb.put_file(session, "big.bin", payload)
fetched = sb.get_file(session, "big.bin")
assert fetched == payload
finally:
sb.close(session)
def test_open_rejects_illegal_session_id(sandbox):
for bad in ["../evil", "a/b", "a b", "a;b", ""]:
with pytest.raises(ValueError):
sandbox.open(bad)
+161
View File
@@ -0,0 +1,161 @@
"""Unit tests for SandboxManager and SandboxCreator using an in-memory backend."""
from typing import Dict, List
import pytest
from application.sandbox.base import CodeSandbox, ExecResult
from application.sandbox.manager import SandboxManager
class FakeBackend(CodeSandbox):
"""In-memory backend recording calls; no network, fully deterministic."""
def __init__(self) -> None:
self.open_calls: List[str] = []
self.attach_calls: List[str] = []
self.closed: List[str] = []
self.files: Dict[str, Dict[str, bytes]] = {}
self._handles: Dict[str, str] = {}
def open(self, session_id: str) -> str:
self.open_calls.append(session_id)
self.files.setdefault(session_id, {})
handle = f"handle-{session_id}-{len(self.open_calls)}"
self._handles[session_id] = handle
return handle
def attach(self, session_id: str) -> str:
self.attach_calls.append(session_id)
if session_id in self._handles:
return self._handles[session_id]
return self.open(session_id)
def close(self, session_id: str) -> None:
self.closed.append(session_id)
self.files.pop(session_id, None)
self._handles.pop(session_id, None)
def exec(self, session_id, code, timeout=None) -> ExecResult:
return ExecResult(status="ok", stdout=f"ran:{code}")
def put_file(self, session_id, dest_path, data) -> None:
self.files.setdefault(session_id, {})[dest_path] = data
def get_file(self, session_id, path) -> bytes:
return self.files[session_id][path]
def list_files(self, session_id) -> List[str]:
return list(self.files.get(session_id, {}).keys())
@pytest.fixture()
def backend() -> FakeBackend:
return FakeBackend()
def test_open_registers_session_and_opens_backend(backend):
mgr = SandboxManager(backend, max_ttl=600)
handle = mgr.open("conv-1")
assert handle.startswith("handle-conv-1")
assert backend.open_calls == ["conv-1"]
assert mgr.has_session("conv-1")
def test_open_twice_reuses_via_attach(backend):
mgr = SandboxManager(backend, max_ttl=600)
mgr.open("conv-1")
mgr.open("conv-1")
assert backend.open_calls == ["conv-1"] # opened once
assert backend.attach_calls == ["conv-1"] # second open reused via attach
def test_attach_reuse_requires_existing_session(backend):
mgr = SandboxManager(backend, max_ttl=600)
with pytest.raises(KeyError):
mgr.attach("missing")
mgr.open("conv-1")
assert mgr.attach("conv-1").startswith("handle-conv-1")
def test_ttl_clamped_to_max(backend):
mgr = SandboxManager(backend, max_ttl=300)
mgr.open("conv-1", ttl=99999)
assert mgr.ttl_for("conv-1") == 300
def test_ttl_honored_when_below_max(backend):
mgr = SandboxManager(backend, max_ttl=300)
mgr.open("conv-1", ttl=120)
assert mgr.ttl_for("conv-1") == 120
def test_ttl_default_used_for_nonpositive(backend):
mgr = SandboxManager(backend, max_ttl=300, default_ttl=200)
mgr.open("conv-1", ttl=0)
assert mgr.ttl_for("conv-1") == 200
mgr.open("conv-2", ttl=-5)
assert mgr.ttl_for("conv-2") == 200
def test_close_drops_registry_and_backend(backend):
mgr = SandboxManager(backend, max_ttl=600)
mgr.open("conv-1")
mgr.close("conv-1")
assert not mgr.has_session("conv-1")
assert backend.closed == ["conv-1"]
def test_reap_expired_closes_idle_sessions(backend, monkeypatch):
clock = {"t": 1000.0}
monkeypatch.setattr("application.sandbox.manager.time.monotonic", lambda: clock["t"])
mgr = SandboxManager(backend, max_ttl=100)
mgr.open("conv-1", ttl=50)
clock["t"] = 1051.0 # 51s idle > 50s ttl
reaped = mgr.reap_expired()
assert reaped == ["conv-1"]
assert backend.closed == ["conv-1"]
assert not mgr.has_session("conv-1")
def test_exec_and_file_roundtrip_through_manager(backend):
mgr = SandboxManager(backend, max_ttl=600)
mgr.open("conv-1")
res = mgr.exec("conv-1", "1+1")
assert res.ok and res.stdout == "ran:1+1"
mgr.put_file("conv-1", "a.txt", b"hello")
assert mgr.get_file("conv-1", "a.txt") == b"hello"
assert mgr.list_files("conv-1") == ["a.txt"]
def test_file_ops_require_open_session(backend):
mgr = SandboxManager(backend, max_ttl=600)
with pytest.raises(KeyError):
mgr.get_file("missing", "a.txt")
def test_sandbox_creator_selects_jupyter_backend(monkeypatch):
from application.sandbox import sandbox_creator as sc
sc.SandboxCreator.reset()
backend = sc.SandboxCreator.create_backend("jupyter")
from application.sandbox.jupyter_gateway import JupyterKernelGatewaySandbox
assert isinstance(backend, JupyterKernelGatewaySandbox)
def test_sandbox_creator_unknown_backend_raises():
from application.sandbox.sandbox_creator import SandboxCreator
with pytest.raises(ValueError):
SandboxCreator.create_backend("does-not-exist")
def test_sandbox_creator_manager_is_singleton():
from application.sandbox.sandbox_creator import SandboxCreator
SandboxCreator.reset()
m1 = SandboxCreator.get_manager()
m2 = SandboxCreator.get_manager()
assert m1 is m2
SandboxCreator.reset()