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:
co-authored by
Claude Opus 4.8
parent
5c1ff0ef58
commit
98fb0d2bdb
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user