§8.1 提到的"专用 sandbox worker"路径:模型生成代码在 Daytona 隔离沙箱执行,不在 本 Pod 跑;只上传生成文件 + sandbox_runner.py,绝不传 env/secret。复用 _runner.py 保证 SandboxResult 一致。fail-soft:Daytona/传输出错回退 Pod 内 subprocess。 双门控(ENABLE_QUALITY_EVAL+HEICODE_SANDBOX_ISOLATED)不变,不削弱 fail-closed。 依赖:加 daytona==0.189.0;为满足其 otel floor 把 otel 1.24→1.42、instrumentation 0.45b0→0.63b1、pydantic 2.9.2→2.13.4(与 agent 对齐)。import + runtime-contract/ security-boundary/result-aggregator 测试在新依赖下验证全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
142 lines
6.0 KiB
Python
142 lines
6.0 KiB
Python
"""Daytona-backed code sandbox — runs model-generated tests in a Daytona cloud sandbox.
|
|
|
|
WHY THIS EXISTS (read with orchestrator/sandbox.py security model and
|
|
docs/integration/security-boundary.md §8.1):
|
|
The default backend executes generated code in a child process **inside this pod**. Daytona
|
|
runs it in a **dedicated external sandbox** (its own isolated VM/container) — exactly the
|
|
"专用 sandbox worker/job" evolution path §8.1 names. Code never touches this pod's process
|
|
space, and we send ONLY the generated files + the test runner (never our env / secrets).
|
|
|
|
GATING IS UNCHANGED. This module is reached only from sandbox.run_tests(), which already called
|
|
assert_isolated() (HEICODE_SANDBOX_ISOLATED) under the ENABLE_QUALITY_EVAL feature gate. Picking
|
|
the Daytona backend does NOT weaken the fail-closed dual gate — it only changes where code runs.
|
|
|
|
CONTRACT: run_tests_daytona() returns the SAME SandboxResult shape as the local backend, by
|
|
reusing sandbox_runner.py verbatim inside the Daytona sandbox and reading its _result.json.
|
|
Raises on any Daytona/transport error so the caller can fail-soft to the local backend.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from typing import List
|
|
|
|
from .sandbox import SandboxFile, SandboxResult, DEFAULT_TIMEOUT_SECONDS
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_RUNNER = Path(__file__).resolve().parent / "sandbox_runner.py"
|
|
_SBX_SUBDIR = "swarm_sbx"
|
|
_RESULT_SENTINEL = "___SBX_RESULT_JSON___"
|
|
_OUTPUT_CAP = 16 * 1024
|
|
|
|
|
|
def daytona_backend_selected() -> bool:
|
|
return os.getenv("SANDBOX_BACKEND", "local").strip().lower() == "daytona"
|
|
|
|
|
|
def _safe_rel(path: str) -> str:
|
|
"""Reject absolute paths / `..` escape — same guard as the local backend's _safe_join."""
|
|
if not path or os.path.isabs(path):
|
|
raise ValueError(f"unsafe file path: {path!r}")
|
|
norm = os.path.normpath(path)
|
|
if norm.startswith("..") or norm.startswith("/"):
|
|
raise ValueError(f"path escapes sandbox: {path!r}")
|
|
return norm
|
|
|
|
|
|
def _client():
|
|
"""Build a Daytona client from env. Raises if the SDK is absent or no api_key is set."""
|
|
from daytona import Daytona, DaytonaConfig # lazy: only when daytona backend is on
|
|
|
|
api_key = os.getenv("DAYTONA_API_KEY")
|
|
if not api_key:
|
|
raise RuntimeError("DAYTONA_API_KEY is not set")
|
|
cfg_kwargs = {"api_key": api_key}
|
|
api_url = os.getenv("DAYTONA_API_URL")
|
|
if api_url:
|
|
cfg_kwargs["api_url"] = api_url
|
|
target = os.getenv("DAYTONA_TARGET")
|
|
if target:
|
|
cfg_kwargs["target"] = target
|
|
return Daytona(DaytonaConfig(**cfg_kwargs))
|
|
|
|
|
|
def _parse_result(stdout: str) -> SandboxResult:
|
|
"""Extract the runner's JSON (after the sentinel) and map it to a SandboxResult."""
|
|
result = SandboxResult()
|
|
result.stdout = (stdout or "")[:_OUTPUT_CAP]
|
|
if not stdout or _RESULT_SENTINEL not in stdout:
|
|
result.error = "no result produced by daytona sandbox runner"
|
|
return result
|
|
raw = stdout.rsplit(_RESULT_SENTINEL, 1)[1].strip()
|
|
try:
|
|
data = json.loads(raw)
|
|
except Exception as exc:
|
|
result.error = f"unparseable daytona result: {exc!r}"
|
|
return result
|
|
result.total = int(data.get("total", 0))
|
|
result.passed = int(data.get("passed", 0))
|
|
result.failed = int(data.get("failed", 0))
|
|
result.errored = int(data.get("errored", 0))
|
|
result.details = data.get("details", []) or []
|
|
if data.get("fatal"):
|
|
result.error = str(data["fatal"])
|
|
return result
|
|
|
|
|
|
def run_tests_daytona(
|
|
source_files: List[SandboxFile],
|
|
test_files: List[SandboxFile],
|
|
*,
|
|
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
) -> SandboxResult:
|
|
"""Run source + test files in a Daytona cloud sandbox; return a SandboxResult.
|
|
|
|
Uploads the generated files + sandbox_runner.py into an ephemeral dir in a freshly-created
|
|
Daytona sandbox, runs the runner (pytest, stdlib fallback), and reads its _result.json. The
|
|
sandbox is always deleted. Raises on Daytona/transport failure (caller fails soft to local).
|
|
"""
|
|
from daytona import FileUpload # lazy
|
|
|
|
runner_src = _RUNNER.read_text(encoding="utf-8")
|
|
exec_timeout = int(timeout_seconds) + 120 # SDK exec timeout: test wall-clock + setup overhead
|
|
|
|
daytona = _client()
|
|
sandbox = daytona.create()
|
|
try:
|
|
root = (sandbox.get_user_root_dir() or "/home/daytona").rstrip("/")
|
|
workdir = f"{root}/{_SBX_SUBDIR}"
|
|
|
|
uploads = [FileUpload(source=runner_src.encode("utf-8"), destination=f"{workdir}/_runner.py")]
|
|
for f in list(source_files) + list(test_files):
|
|
rel = _safe_rel(f.path)
|
|
uploads.append(FileUpload(source=f.content.encode("utf-8"),
|
|
destination=f"{workdir}/{rel}"))
|
|
sandbox.fs.upload_files(uploads)
|
|
|
|
# Best-effort pytest install; runner falls back to stdlib if it's unavailable. Quiet, and
|
|
# never fatal — a non-zero pip exit still lets the runner produce a result.
|
|
try:
|
|
sandbox.process.exec("python -m pip install -q pytest 2>/dev/null || true",
|
|
timeout=exec_timeout)
|
|
except Exception as exc: # pragma: no cover - network dependent
|
|
logger.info("daytona: pytest install best-effort failed (runner will fall back): %s", exc)
|
|
|
|
# Run the runner, then print _result.json after a sentinel so we can parse it out of the
|
|
# combined stdout/stderr stream (we never trust loose stdout for counts).
|
|
cmd = (f"cd {workdir} && python -I _runner.py >/dev/null 2>&1; "
|
|
f"echo {_RESULT_SENTINEL}; cat _result.json")
|
|
resp = sandbox.process.exec(cmd, timeout=exec_timeout)
|
|
result = _parse_result(getattr(resp, "result", "") or "")
|
|
if result.exit_code is None:
|
|
result.exit_code = getattr(resp, "exit_code", None)
|
|
return result
|
|
finally:
|
|
try:
|
|
sandbox.delete()
|
|
except Exception as exc: # pragma: no cover - cleanup best-effort
|
|
logger.warning("daytona: sandbox delete failed (may leak a sandbox): %s", exc)
|