26b4a5be60
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---
96 lines
3.2 KiB
Python
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."""
|
|
...
|