feat(sandbox): Daytona 云沙箱后端 — 代码执行移出 Pod(SANDBOX_BACKEND=daytona)

§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>
This commit is contained in:
gongzhiyong
2026-06-21 21:14:07 +08:00
co-authored by Claude Opus 4.8
parent 5c1ff0ef58
commit 98fb0d2bdb
4 changed files with 184 additions and 7 deletions
+14
View File
@@ -86,6 +86,11 @@ spec:
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef: { name: swarm-model-key, key: OPENAI_API_KEY }
# Jina MCP key — launcher 透传给 agent pod(SENSITIVE → per-swarm Secret),
# agent 用它连 mcp.jina.ai 取 search_web/read_url 工具做 function-calling。
- name: JINA_API_KEY
valueFrom:
secretKeyRef: { name: swarm-jina-key, key: JINA_API_KEY }
# --- agent 池窗口 ---
- name: AGENT_LAUNCH_MIN_POOL
value: "16"
@@ -113,6 +118,15 @@ spec:
value: "1"
- name: HEICODE_SANDBOX_ISOLATED
value: "1"
# 代码沙箱后端:daytona = 在 Daytona 云沙箱执行模型生成代码(不在本 Pod 跑,隔离更强);
# 出错 fail-soft 回退 Pod 内 subprocess。双门控不变。
- name: SANDBOX_BACKEND
value: "daytona"
- name: DAYTONA_API_URL
value: "https://app.daytona.io/api"
- name: DAYTONA_API_KEY
valueFrom:
secretKeyRef: { name: swarm-daytona-key, key: DAYTONA_API_KEY }
# --- SWE-bench 评测:产 unified diff,不 push 到 main ---
- name: SWARM_EMIT_PATCH
value: "1"
+15 -7
View File
@@ -3,7 +3,8 @@ uvicorn[standard]==0.32.0
websockets==13.1
redis==5.2.0
openai==1.55.3
pydantic==2.9.2
# pydantic 2.11+ required by the Daytona SDK (daytona-api-client); 2.13.4 matches the agent pin.
pydantic==2.13.4
python-dotenv==1.0.1
prometheus-client==0.20.0
httpx==0.28.1
@@ -12,12 +13,15 @@ fakeredis==2.26.1
# sandbox test runner: pytest natively runs the test styles agents produce (pytest-style classes,
# fixtures, parametrize, marks) — the in-pod harness (sandbox_runner.py) prefers it, stdlib fallback.
pytest==8.3.3
opentelemetry-api==1.24.0
opentelemetry-sdk==1.24.0
opentelemetry-exporter-otlp==1.24.0
opentelemetry-instrumentation-redis==0.45b0
opentelemetry-instrumentation-requests==0.45b0
opentelemetry-instrumentation-logging==0.45b0
# otel bumped 1.24→1.42 to satisfy the Daytona SDK's otel floor (instrumentation-aiohttp-client
# >=0.59b0); instrumentation pins move 0.45b0→0.63b1 in lockstep. Import + contract/security/
# aggregator tests verified green under this set.
opentelemetry-api==1.42.1
opentelemetry-sdk==1.42.1
opentelemetry-exporter-otlp==1.42.1
opentelemetry-instrumentation-redis==0.63b1
opentelemetry-instrumentation-requests==0.63b1
opentelemetry-instrumentation-logging==0.63b1
# benchmark metrics export targets (lazy-imported; only used when BENCHMARK_EXPORT_TARGET set)
azure-cosmos==4.7.0
azure-storage-blob==12.23.1
@@ -25,3 +29,7 @@ azure-identity==1.19.0
# model-key resolution from Key Vault via Pod workload identity (lazy-imported in
# agent_launcher._resolve_from_keyvault; only when AZURE_FEDERATED_TOKEN_FILE / SECRET_RESOLVER=azkv)
azure-keyvault-secrets==4.9.0
# Daytona cloud sandbox backend (orchestrator/sandbox_daytona.py): runs model-generated tests in a
# dedicated external sandbox (code never executes in this pod). Lazy-imported, only when
# SANDBOX_BACKEND=daytona. Drives the otel/pydantic bumps above.
daytona==0.189.0
+14
View File
@@ -22,6 +22,7 @@ SECURITY MODEL — read before changing anything here:
from __future__ import annotations
import json
import logging
import os
import shutil
import subprocess
@@ -31,6 +32,8 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
logger = logging.getLogger(__name__)
try: # POSIX only; absent on Windows dev boxes (prod is a Linux pod)
import resource # type: ignore
except Exception: # pragma: no cover - platform dependent
@@ -164,6 +167,17 @@ def run_tests(
is spawned.
"""
assert_isolated()
# Backend selection. `daytona` runs the code in a dedicated external Daytona sandbox (code never
# touches this pod). Fail-soft: any Daytona/transport error falls back to the in-pod child below
# — same fail-closed gate already passed (assert_isolated above), so this is safe, just noisier.
if os.getenv("SANDBOX_BACKEND", "local").strip().lower() == "daytona":
try:
from .sandbox_daytona import run_tests_daytona
return run_tests_daytona(source_files, test_files, timeout_seconds=timeout_seconds)
except Exception as exc:
logger.warning("sandbox: daytona backend failed (%s); falling back to in-pod child", exc)
workdir = Path(tempfile.mkdtemp(prefix="swarm-sbx-"))
is_posix = os.name == "posix"
try:
+141
View File
@@ -0,0 +1,141 @@
"""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)