Files
nextcraft/apps/ai-service/ai_service/sandbox/manager.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

457 lines
17 KiB
Python

"""SandboxManager — lifecycle, concurrency guard, reapers (REQ-3-001, REQ-3-002).
Responsibilities (D-032, G-2, a-1):
- create/list/get/snapshot/destroy over a `SandboxBackend` port. create/list/
get return `SandboxHandleInfo` rows — the handle fields plus the owning
`learner_id` — so the API layer never re-asks "who owns this id?".
- Capacity guard (D-032): `create` raises `PoolFullError` when the active
count reaches `settings.sandbox_max_concurrent`. No queue — the API layer
maps this to 503.
- Wall-clock reaper: `reap_expired()` destroys sandboxes older than
`settings.sandbox_timeout_s`. Run it on an async timer owned by the caller
(app lifespan wires the loop; the manager owns only the pass).
- Workdir-size sweep (G-2): the same timer pass also measures each sandbox's
`workspace/` tree; anything over `settings.sandbox_max_workdir_mb` is
snapshotted (evidence preserved), destroyed, and recorded as an integrity
signal. SOFT CAP, best-effort, NOT kernel-enforced — without cgroup
delegation or sudo there is no hard per-sandbox disk quota on this host.
RLIMIT_FSIZE bounds a single file; this sweep bounds aggregate growth
between passes.
- Startup reaper (a-1): `start()` scans `settings.sandbox_dir` for workdirs
whose recorded pid is dead (marker file `sandbox.json` beside workspace/)
and reaps them, logging a warning. Handles are IN-MEMORY and process-local
(D-019 precedent): on process restart every handle is orphaned, so boot
must recover disk state.
Registry: plain dict guarded by an `asyncio.Lock`, process-local, explicitly
NOT a store. Swapping in persistence (D-027) must not change this interface.
Resource limits: enforced at exec time by the spawner's in-namespace shim
(RLIMIT_AS / RLIMIT_CPU / RLIMIT_FSIZE — see UnshareBackend), never here;
the manager's enforcement surface is lifecycle (capacity, wall-clock, disk
sweep).
BOUNDARY: this module NEVER imports `api/` or `agents/`.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import shutil
import uuid
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from pydantic import BaseModel, ConfigDict, Field
from ..config import Settings
from . import workdir as workdir_mod
from .backend import SandboxBackend, SandboxHandle
logger = logging.getLogger(__name__)
#: Marker file beside workspace/ recording the owning process + metadata.
#: It is what the startup reaper uses after this process's in-memory
#: registry is lost (crash/restart → orphan detection, a-1).
PID_MARKER = "sandbox.json"
class PoolFullError(RuntimeError):
"""D-032: active sandbox count reached `settings.sandbox_max_concurrent`.
The API layer maps this to 503. There is deliberately NO queue.
"""
class SandboxNotFoundError(KeyError):
"""No live sandbox with that id in this process's registry."""
class SandboxIntegrityEvent(BaseModel):
"""One manager-observed integrity signal (G-2).
Recorded in-process (`SandboxManager.integrity_events`, for the proctor
pipeline to drain) AND logged at WARNING (durable trail) — the same
dual-sink pattern a DB-backed store will keep behind D-027.
"""
model_config = ConfigDict(frozen=True)
kind: str = Field(description="e.g. 'workdir_size_cap' (G-2), 'orphan_reaped' (a-1)")
sandbox_id: str
learner_id: str
detail: str
observed_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
class SandboxHandleInfo(BaseModel):
"""A `SandboxHandle` plus its owning `learner_id` (manager return row).
Handles alone don't carry the learner — the registry side-table does —
and every API read/list needs it, so the manager joins the two ONCE here
instead of exposing `_learner_ids` internals.
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
id: str
learner_id: str
workdir: Path
created_at: datetime
pid: int | None = None
class SandboxManager:
"""Lifecycle owner for learner sandboxes.
Dependencies are injected (D-017 style): the backend port, settings, and
a wall clock. Single-process only; the registry is in-memory.
"""
def __init__(
self,
*,
backend: SandboxBackend,
settings: Settings,
clock: Callable[[], datetime] | None = None,
) -> None:
self._backend = backend
self._settings = settings
self._clock = clock or (lambda: datetime.now(UTC))
self._handles: dict[str, SandboxHandle] = {}
self._learner_ids: dict[str, str] = {} # sandbox_id -> learner_id
self._lock = asyncio.Lock()
self._integrity_events: list[SandboxIntegrityEvent] = []
self._started = False
# -- introspection ------------------------------------------------------
@property
def active_count(self) -> int:
return len(self._handles)
@property
def integrity_events(self) -> list[SandboxIntegrityEvent]:
"""Drainable view of recorded integrity signals (G-2, a-1)."""
return list(self._integrity_events)
# -- lifecycle ----------------------------------------------------------
async def create(
self, learner_id: str, task_id: str | None = None
) -> SandboxHandleInfo:
"""Spawn a sandbox for `learner_id`, or raise `PoolFullError` (D-032).
`task_id` (REQ-3-003): when set, the sandbox is telemetry-wired — the
backend copies `scripts/sandbox-agent.py` into the workdir and starts
the stdlib capture agent inside the sandbox with the `NC_*` env baked
here (identity + WS ingest URL). The agent's lifecycle is tied to the
sandbox: `destroy()` reaps it (agent → inner → helper). When `task_id`
is None the sandbox is a pure shell sandbox (no capture).
"""
async with self._lock:
if len(self._handles) >= self._settings.sandbox_max_concurrent:
raise PoolFullError(
f"sandbox pool full "
f"({len(self._handles)}/{self._settings.sandbox_max_concurrent}); "
"no queue (D-032) — retry later"
)
sandbox_id = f"sbx-{uuid.uuid4().hex[:12]}"
spec = workdir_mod.spec_for(sandbox_id, learner_id, self._settings)
if task_id is not None:
spec = spec.model_copy(
update={
"capture_env": self._capture_env(sandbox_id, learner_id, task_id)
}
)
handle = await self._backend.spawn(spec)
self._handles[handle.id] = handle
self._learner_ids[handle.id] = learner_id
self._write_pid_marker(handle, learner_id)
logger.info(
"sandbox created: id=%s learner=%s task=%s",
handle.id,
learner_id,
task_id or "-",
)
return self._info_for(handle)
def _capture_env(self, sandbox_id: str, learner_id: str, task_id: str) -> dict[str, str]:
"""Env baked for the in-sandbox capture agent (REQ-3-003).
The agent joins the sandbox mount namespace but NOT its (offline)
network namespace, so it reaches this service over loopback
(`telemetry_ingest_host`, A-004 port).
"""
from urllib.parse import urlencode
query = urlencode(
{
"learner_id": learner_id,
"task_id": task_id,
"sandbox_id": sandbox_id,
}
)
ingest_url = (
f"ws://{self._settings.telemetry_ingest_host}:{self._settings.port}"
f"/v1/telemetry/ingest?{query}"
)
return {
"NC_LEARNER_ID": learner_id,
"NC_TASK_ID": task_id,
"NC_SANDBOX_ID": sandbox_id,
"NC_INGEST_URL": ingest_url,
}
async def list(self) -> list[SandboxHandleInfo]:
"""All live sandboxes (idle + busy; the backend has no busy flag)."""
async with self._lock:
return [self._info_for(h) for h in self._handles.values()]
async def get(self, sandbox_id: str) -> SandboxHandleInfo:
async with self._lock:
handle = self._handles.get(sandbox_id)
learner_id = self._learner_ids.get(sandbox_id, "unknown")
if handle is None:
raise SandboxNotFoundError(sandbox_id)
return SandboxHandleInfo(
id=handle.id,
learner_id=learner_id,
workdir=handle.workdir,
created_at=handle.created_at,
pid=handle.pid,
)
async def snapshot(self, sandbox_id: str) -> Path:
"""Copy the workspace into `<workdir>/snapshots/<utc-ts>/`; return it."""
async with self._lock:
handle = self._handles.get(sandbox_id)
if handle is None:
raise SandboxNotFoundError(sandbox_id)
return await self._backend.snapshot(handle)
async def destroy(self, sandbox_id: str, *, purge_workdir: bool = False) -> None:
"""Tear down one sandbox (idempotent).
`purge_workdir=False` keeps the workdir on disk — snapshots must
survive destroy so a learner's last state can be restored (this is
also why UnshareBackend.destroy intentionally leaves the tree alone).
`purge_workdir=True` removes the whole workdir.
"""
async with self._lock:
handle = self._handles.pop(sandbox_id, None)
learner_id = self._learner_ids.pop(sandbox_id, "unknown")
if handle is not None:
await self._backend.destroy(handle)
logger.info(
"sandbox destroyed: id=%s learner=%s purge=%s",
sandbox_id,
learner_id,
purge_workdir,
)
root = handle.workdir
else:
# Idempotent destroy of an unknown id: resolve the on-disk root so
# an explicit purge still works (e.g. cleanup of orphan leftovers).
root = workdir_mod.resolve_sandbox_dir(self._settings) / sandbox_id
if purge_workdir:
shutil.rmtree(root, ignore_errors=True)
# -- reapers ------------------------------------------------------------
async def reap_expired(self) -> list[str]:
"""One reaper pass: wall-clock timeout + G-2 workdir-size sweep.
Destroys sandboxes older than `settings.sandbox_timeout_s`, then sweeps
every remaining sandbox whose `workspace/` exceeds
`settings.sandbox_max_workdir_mb` (snapshot → destroy → integrity
signal). Returns the ids destroyed this pass. Invoke on an async timer
(the app lifespan owns the loop interval); both checks deliberately
share one pass so the periodic work is O(live sandboxes) once.
"""
now = self._clock()
destroyed: list[str] = []
timeout_s = float(self._settings.sandbox_timeout_s)
cap_bytes = int(self._settings.sandbox_max_workdir_mb) * 1024 * 1024
async with self._lock:
rows = [
(handle, self._learner_ids.get(handle.id, "unknown"), handle.created_at)
for handle in self._handles.values()
]
for handle, learner_id, created_at in rows:
age_s = (now - created_at).total_seconds()
if age_s > timeout_s:
await self.destroy(handle.id)
destroyed.append(handle.id)
logger.warning(
"sandbox reaped (timeout): id=%s age=%.0fs > %.0fs",
handle.id,
age_s,
timeout_s,
)
continue # already gone; no size sweep needed on a dead handle
size = _tree_size_bytes(workdir_mod.workspace_path_from_workdir(handle.workdir))
if size > cap_bytes:
await self._reap_oversized(handle, learner_id, size, cap_bytes)
destroyed.append(handle.id)
return destroyed
async def start(self) -> None:
"""Boot hook (a-1): reap on-disk orphans left by a previous process.
The handle registry is in-memory and process-local (D-019 precedent):
after a restart nothing here remembers old sandboxes, so we scan
`settings.sandbox_dir` for workdirs whose pid marker names a dead
process and purge them, logging a warning. Idempotent; safe to call
once per process lifetime.
"""
if self._started:
return
self._started = True
root = workdir_mod.resolve_sandbox_dir(self._settings)
if not root.is_dir():
return
for entry in sorted(root.iterdir()):
if not entry.is_dir():
continue
marker = entry / PID_MARKER
pid = _read_marker_pid(marker)
if pid is not None and _pid_alive(pid):
continue # live sandbox owned by another live process — leave it
logger.warning(
"startup reaper (a-1): reaping orphaned workdir %s "
"(recorded pid %s is dead or marker missing)",
entry,
pid,
)
shutil.rmtree(entry, ignore_errors=True)
self._record_integrity(
SandboxIntegrityEvent(
kind="orphan_reaped",
sandbox_id=entry.name,
learner_id="unknown",
detail=f"workdir {entry} reaped at boot; recorded pid={pid} dead",
)
)
async def destroy_all(self) -> None:
"""Shutdown hook: destroy every live sandbox (no orphans on exit).
Workdirs (and their snapshots) are kept on disk — destroy semantics
here match `destroy(purge_workdir=False)`; the next boot's startup
reaper (a-1) decides what to clean based on pid markers.
"""
async with self._lock:
handles = list(self._handles.values())
for handle in handles:
await self.destroy(handle.id)
# -- internals ------------------------------------------------------------
def _info_for(self, handle: SandboxHandle) -> SandboxHandleInfo:
# Caller holds the lock (create/list) — the side-table read is atomic.
return SandboxHandleInfo(
id=handle.id,
learner_id=self._learner_ids.get(handle.id, "unknown"),
workdir=handle.workdir,
created_at=handle.created_at,
pid=handle.pid,
)
def _write_pid_marker(self, handle: SandboxHandle, learner_id: str) -> None:
marker = handle.workdir / PID_MARKER
try:
marker.write_text(
json.dumps(
{
"sandbox_id": handle.id,
"learner_id": learner_id,
"pid": os.getpid(),
"created_at": handle.created_at.isoformat(),
}
)
)
except OSError: # marker is advisory; spawning must not fail on it
logger.warning("could not write pid marker %s", marker)
async def _reap_oversized(
self,
handle: SandboxHandle,
learner_id: str,
size_bytes: int,
cap_bytes: int,
) -> None:
"""G-2 sweep step: snapshot evidence → destroy → record the signal."""
snapshot_path: Path | None = None
try:
snapshot_path = await self._backend.snapshot(handle)
except (OSError, RuntimeError):
logger.exception(
"G-2 sweep: snapshot failed for over-cap sandbox %s; destroying anyway",
handle.id,
)
await self.destroy(handle.id)
event = SandboxIntegrityEvent(
kind="workdir_size_cap",
sandbox_id=handle.id,
learner_id=learner_id,
detail=(
f"workspace {size_bytes}B exceeded soft cap {cap_bytes}B; "
f"snapshot={snapshot_path} then destroyed (G-2, best-effort, "
"NOT kernel-enforced)"
),
)
self._record_integrity(event)
logger.warning(
"G-2 workdir sweep: sandbox %s (learner=%s) destroyed over soft disk cap",
handle.id,
learner_id,
)
def _record_integrity(self, event: SandboxIntegrityEvent) -> None:
self._integrity_events.append(event)
# -- module helpers ---------------------------------------------------------
def _tree_size_bytes(root: Path) -> int:
"""Total bytes under `root` (best-effort; unreadable entries count 0)."""
if not root.is_dir():
return 0
total = 0
for dirpath, _dirnames, filenames in os.walk(root):
for name in filenames:
try:
total += (Path(dirpath) / name).lstat().st_size
except OSError:
continue
return total
def _read_marker_pid(marker: Path) -> int | None:
try:
data = json.loads(marker.read_text())
except (OSError, json.JSONDecodeError):
return None
pid = data.get("pid")
return pid if isinstance(pid, int) else None
def _pid_alive(pid: int) -> bool:
"""True if `pid` exists on this host (signal 0 probe; no signal sent)."""
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True # exists, owned by another user
return True