Files
nextcraft/apps/ai-service/ai_service/sandbox/backend.py
T
CIAgent 26b4a5be60 feat(P02): telemetry-wired sandbox spawn (Wave 3)
Task 2-3-01: create(learner_id, task_id) — telemetry sandboxes run a persistent
helper/inner namespace topology (offline inner ns; agent joins mount ns only and stays
online to reach the loopback ingest). Capture agent copied into the workspace (visible
in-ns at the bind), launched via sh -c with in-ns absolute paths (host cwd invalid after
the nsenter mount swap), stdin=DEVNULL daemonizes the agent (lifecycle tied to sandbox:
destroy reaps agent -> inner -> helper). Wire contract: frames strip URL-owned identity
(ingest extra=forbid anti-spoofing); spool keeps full events.

E2E test (real uvicorn on ephemeral port): exec in a live namespace sandbox -> events
arrive at WS ingest -> SQLite, ordered, sandbox-scoped. Pure-shell path (task_id=None)
asserts no capture agent. Full suite 216 green; ruff clean.

---ci---
phase: 2
milestone: v0.3
status: execute
requirements: {covered: [REQ-3-003], partial: []}
---/ci---
2026-09-12 00:47:45 +00:00

96 lines
3.2 KiB
Python

"""SandboxBackend protocol + pydantic contracts (REQ-3-001, D-024).
`SandboxBackend` is the port the sandbox fabric depends on. The only
implementation in v0.3 is `unshare_backend.UnshareBackend`; a future
firecracker/bwrap backend must satisfy this same surface.
Contracts are plain pydantic models so API/agent layers can construct and
validate them at the request boundary without importing the backend itself.
"""
from datetime import datetime
from pathlib import Path
from typing import Protocol, runtime_checkable
from pydantic import BaseModel, ConfigDict, Field
class ResourceLimits(BaseModel):
"""Per-sandbox rlimits, applied via `preexec_fn` immediately before exec.
- memory_bytes → RLIMIT_AS (address space; hard OOM ceiling)
- cpu_seconds → RLIMIT_CPU (CPU-seconds; SIGKILL on hard expiry)
- file_size_bytes → RLIMIT_FSIZE (~50 MB single-file cap)
RLIMIT_NPROC is NOT set: the counter is shared per host UID across all
namespaces, so it cannot isolate one sandbox from another on this host.
Total disk usage is enforced by the manager sweep (G-2), not here.
"""
model_config = ConfigDict(frozen=True)
memory_bytes: int = Field(default=256 * 1024 * 1024, gt=0)
cpu_seconds: int = Field(default=30, gt=0)
file_size_bytes: int = Field(default=50 * 1024 * 1024, gt=0)
class SandboxHandle(BaseModel):
"""A live (or reaped) sandbox. `pid` is None once `destroy()` completes."""
model_config = ConfigDict(arbitrary_types_allowed=True)
id: str
pid: int | None
workdir: Path
created_at: datetime
class SandboxSpec(BaseModel):
"""Immutable description of the sandbox to lay out on disk.
`capture_env` (REQ-3-003): when non-empty, the backend starts a persistent
telemetry-wired sandbox — helper + inner namespaces + the stdlib capture
agent, launched with these env vars (NC_LEARNER_ID, NC_TASK_ID,
NC_INGEST_URL, NC_SANDBOX_ID). When None (default), spawn keeps the pure
shell semantics (REQ-3-001): disk layout only, fresh namespaces per exec.
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
sandbox_id: str
learner_id: str
workdir: Path
limits: ResourceLimits = ResourceLimits()
capture_env: dict[str, str] | None = None
class ExecResult(BaseModel):
"""One namespaced execution: cwd = the bind-mounted workspace (`/work`)."""
cmd: list[str]
returncode: int
stdout: str
stderr: str
duration_s: float
@runtime_checkable
class SandboxBackend(Protocol):
"""The sandbox port. Backends spawn subprocesses; they never touch HTTP."""
async def spawn(self, spec: SandboxSpec) -> SandboxHandle:
"""Create the sandbox from `spec` and return its handle."""
...
async def exec(self, handle: SandboxHandle, cmd: list[str]) -> ExecResult:
"""Run `cmd` inside the sandbox workspace; capture stdout/stderr."""
...
async def snapshot(self, handle: SandboxHandle) -> Path:
"""Copy the workspace into `<workdir>/snapshots/<utc-ts>/`; return the path."""
...
async def destroy(self, handle: SandboxHandle) -> None:
"""Tear the sandbox down. Must be idempotent."""
...