12b2300f6f
---ci--- phase: 7 milestone: v0.3 status: review lessons: - P0 CORS: allow_methods lacked PUT while the build surface writes files with PUT — every cross-origin Save failed preflight; pinned with tests/api/test_cors.py - P0 ingest leak: queue-overflow flood path returned without the disconnect sentinel, parking the drainer forever (one leaked task-set per flooded trace); sentinel now always enqueued, real-server regression test added - P1 perf: flood cap counted rows via len(get_trace(...)) — O(trace) per append, O(n²) per session; TraceStore.count() (COUNT(*)) added and wired - P0 security: file routes followed exec-planted symlinks out of the workspace bind; _resolve_in_workspace refuses escapes (422), read/write now 404 on unknown sandboxes (was 500) - P1 security: WS ingest accepted any browser Origin (CORS middleware does not cover WS); localhost dev origins + no-Origin (capture agent) allowed, others 1008 - P1 correctness: use-sandbox-session leaked a created sandbox on any mid-start failure (per-learner cap 1 → all retries 429 forever); failed starts now destroy what they created - P2 testing: reconnect-flush test killed mid-burst (nondeterministic under load, reproduced on pre-change code); now waits for server-side observation of the pre-kill burst — the underlying one-line replay-margin/ACK gap is documented for v0.4 - maintainability: grading-store/templates/grading.ts docstrings claimed grading is variant-blind (stale pre-P4 text) — updated; ARCHITECTURE.md referenced nonexistent voice/openai_audio.py; dead if TYPE_CHECKING: pass blocks removed ---/ci---
354 lines
13 KiB
Python
354 lines
13 KiB
Python
"""/v1/sandboxes — sandbox lifecycle API with G-5 abuse control (REQ-3-001).
|
|
|
|
The sandbox fabric (`sandbox/manager.py`) owns lifecycle; this module owns the
|
|
HTTP contract and the abuse gates, which are MIDDLEWARE-LAYER concerns and
|
|
therefore live here, never in the manager:
|
|
|
|
allowlist (403) G-5: `learner_id` must be in
|
|
`settings.learner_allowlist`. This is NOT auth —
|
|
KYC/identity is deferred; the allowlist only keeps
|
|
unvetted ids from spawning namespaces on this box.
|
|
per-learner cap (429) `settings.sandbox_max_per_learner` ACTIVE sandboxes
|
|
per learner (default 1 — one pilot, one box).
|
|
global create cap (429) `settings.sandbox_creates_per_min` creates per
|
|
rolling 60s window across all learners; in-memory,
|
|
process-local (matches the handle registry, D-019).
|
|
pool full (503) D-032 capacity guard (`PoolFullError`), no queue.
|
|
|
|
Response models are local to the api/ surface. The manager returns
|
|
`SandboxHandleInfo` rows (handle fields + learner_id); the response `pid`
|
|
field is typed `int | None` and excluded — a host-process detail that is
|
|
never part of the API contract.
|
|
"""
|
|
|
|
import time
|
|
from collections import deque
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from ..config import Settings
|
|
from ..sandbox.manager import (
|
|
PoolFullError,
|
|
SandboxHandleInfo,
|
|
SandboxManager,
|
|
SandboxNotFoundError,
|
|
)
|
|
from ..sandbox.workdir import SandboxDir
|
|
from .deps import get_sandbox_manager, get_sandbox_test_layout, get_settings
|
|
|
|
router = APIRouter(prefix="/v1/sandboxes", tags=["sandboxes"])
|
|
|
|
# In-memory create-rate window (monotonic timestamps, process-local). Module
|
|
# state is acceptable here for the same reason the handle registry is: one
|
|
# process, one box, no store (D-019/D-027 precedent).
|
|
_CREATE_TIMES: deque[float] = deque()
|
|
|
|
|
|
# -- contracts ----------------------------------------------------------------
|
|
|
|
|
|
class SandboxCreateRequest(BaseModel):
|
|
learner_id: str = Field(min_length=1)
|
|
# Optional task key: when set, the sandbox is telemetry-wired (REQ-3-003)
|
|
# — the in-sandbox capture agent streams workspace events to the ingest.
|
|
task_id: str | None = None
|
|
|
|
|
|
class SandboxResponse(BaseModel):
|
|
"""Public sandbox handle. `workdir` is the absolute host path."""
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
id: str
|
|
learner_id: str
|
|
workdir: str
|
|
created_at: str
|
|
pid: int | None = Field(
|
|
default=None,
|
|
exclude=True, # host-process detail; never part of the API contract
|
|
)
|
|
|
|
|
|
def _to_response(info: SandboxHandleInfo) -> SandboxResponse:
|
|
return SandboxResponse(
|
|
id=info.id,
|
|
learner_id=info.learner_id,
|
|
workdir=str(info.workdir),
|
|
created_at=info.created_at.isoformat(),
|
|
pid=info.pid,
|
|
)
|
|
|
|
|
|
class SandboxListResponse(BaseModel):
|
|
sandboxes: list[SandboxResponse]
|
|
|
|
|
|
class SnapshotResponse(BaseModel):
|
|
sandbox_id: str
|
|
snapshot_path: str
|
|
files: list[str]
|
|
|
|
|
|
# -- abuse control (G-5; middleware layer, not auth) ---------------------------
|
|
|
|
|
|
def _enforce_allowlist(learner_id: str, settings: Settings) -> None:
|
|
if learner_id not in settings.learner_allowlist:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=f"learner_id {learner_id!r} is not on the sandbox allowlist (G-5)",
|
|
)
|
|
|
|
|
|
def _check_per_learner_cap(
|
|
infos: list[SandboxHandleInfo], learner_id: str, settings: Settings
|
|
) -> None:
|
|
active = sum(1 for info in infos if info.learner_id == learner_id)
|
|
if active >= settings.sandbox_max_per_learner:
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail=(
|
|
f"learner {learner_id!r} already has {active} active "
|
|
f"sandbox(es); per-learner cap is {settings.sandbox_max_per_learner}"
|
|
),
|
|
)
|
|
|
|
|
|
def _check_global_create_rate(settings: Settings) -> None:
|
|
"""Sliding-window global create cap. Admitted only after ALL checks pass,
|
|
so a rejected create never consumes budget."""
|
|
now = time.monotonic()
|
|
while _CREATE_TIMES and now - _CREATE_TIMES[0] > 60.0:
|
|
_CREATE_TIMES.popleft()
|
|
if len(_CREATE_TIMES) >= settings.sandbox_creates_per_min:
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail=(
|
|
f"global sandbox create rate exceeded "
|
|
f"({settings.sandbox_creates_per_min}/min); retry shortly"
|
|
),
|
|
)
|
|
_CREATE_TIMES.append(now)
|
|
|
|
|
|
# -- endpoints ---------------------------------------------------------------
|
|
|
|
|
|
@router.post("", status_code=201, response_model=SandboxResponse)
|
|
async def create_sandbox(
|
|
body: SandboxCreateRequest,
|
|
manager: SandboxManager = Depends(get_sandbox_manager),
|
|
settings: Settings = Depends(get_settings),
|
|
) -> SandboxResponse:
|
|
_enforce_allowlist(body.learner_id, settings)
|
|
_check_per_learner_cap(await manager.list(), body.learner_id, settings)
|
|
_check_global_create_rate(settings)
|
|
try:
|
|
info = await manager.create(body.learner_id, task_id=body.task_id)
|
|
except PoolFullError as exc:
|
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
return _to_response(info)
|
|
|
|
|
|
@router.get("", response_model=SandboxListResponse)
|
|
async def list_sandboxes(
|
|
manager: SandboxManager = Depends(get_sandbox_manager),
|
|
) -> SandboxListResponse:
|
|
return SandboxListResponse(
|
|
sandboxes=[_to_response(info) for info in await manager.list()]
|
|
)
|
|
|
|
|
|
@router.get("/{sandbox_id}", response_model=SandboxResponse)
|
|
async def get_sandbox(
|
|
sandbox_id: str,
|
|
manager: SandboxManager = Depends(get_sandbox_manager),
|
|
) -> SandboxResponse:
|
|
try:
|
|
info = await manager.get(sandbox_id)
|
|
except SandboxNotFoundError:
|
|
raise HTTPException(
|
|
status_code=404, detail=f"unknown sandbox {sandbox_id!r}"
|
|
) from None
|
|
return _to_response(info)
|
|
|
|
|
|
@router.post("/{sandbox_id}/snapshot", response_model=SnapshotResponse)
|
|
async def snapshot_sandbox(
|
|
sandbox_id: str,
|
|
response: Response,
|
|
manager: SandboxManager = Depends(get_sandbox_manager),
|
|
test_layout: SandboxDir | None = Depends(get_sandbox_test_layout),
|
|
) -> SnapshotResponse:
|
|
try:
|
|
snapshot_path = await manager.snapshot(sandbox_id)
|
|
except SandboxNotFoundError:
|
|
raise HTTPException(
|
|
status_code=404, detail=f"unknown sandbox {sandbox_id!r}"
|
|
) from None
|
|
# Test seam: a copied workspace keeps the `files` assertion honest for
|
|
# fast stub-backend tests; production always hits the real snapshot above.
|
|
if test_layout is not None and test_layout.snapshots.is_dir():
|
|
copies = sorted(test_layout.snapshots.iterdir())
|
|
if copies:
|
|
response.headers["X-Snapshot-Copy"] = str(copies[-1])
|
|
return SnapshotResponse(
|
|
sandbox_id=sandbox_id,
|
|
snapshot_path=str(snapshot_path),
|
|
files=sorted(p.name for p in snapshot_path.iterdir()),
|
|
)
|
|
|
|
|
|
@router.delete("/{sandbox_id}", status_code=204)
|
|
async def delete_sandbox(
|
|
sandbox_id: str,
|
|
manager: SandboxManager = Depends(get_sandbox_manager),
|
|
test_layout: SandboxDir | None = Depends(get_sandbox_test_layout),
|
|
) -> Response:
|
|
try:
|
|
await manager.get(sandbox_id)
|
|
except SandboxNotFoundError:
|
|
raise HTTPException(
|
|
status_code=404, detail=f"unknown sandbox {sandbox_id!r}"
|
|
) from None
|
|
# Workdir is KEPT (purge_workdir=False): snapshots must survive destroy so
|
|
# a learner's last state can be restored. The periodic G-2 reaper owns
|
|
# quota; explicit purge is an ops action, not an API verb.
|
|
await manager.destroy(sandbox_id, purge_workdir=False)
|
|
result = Response(status_code=204)
|
|
if test_layout is not None:
|
|
result.headers["X-Workspace-Copy"] = str(test_layout.workspace)
|
|
return result
|
|
|
|
|
|
# -- workspace files + exec (Phase 6, REQ-3-008; CUT-2) -------------------------
|
|
#
|
|
# The build surface reads/writes/list workspace files and runs Run/Test
|
|
# commands through the manager's backend. NO interactive shell relay (CUT-2:
|
|
# keystroke-level stdin/stdout is v0.4) — each exec is a bounded command with
|
|
# captured output. Paths are WORKSPACE-RELATIVE; traversal outside the
|
|
# workspace is rejected (the workdir bind is the boundary, but the API adds
|
|
# its own containment check — defense in depth).
|
|
|
|
|
|
class FileWriteRequest(BaseModel):
|
|
path: str = Field(min_length=1)
|
|
content: str
|
|
|
|
|
|
class ExecRequest(BaseModel):
|
|
cmd: list[str] = Field(min_length=1)
|
|
|
|
|
|
class ExecResponse(BaseModel):
|
|
cmd: list[str]
|
|
returncode: int
|
|
stdout: str
|
|
stderr: str
|
|
duration_s: float
|
|
|
|
|
|
async def _workspace_dir(manager: SandboxManager, sandbox_id: str):
|
|
"""Resolve the sandbox workspace (tracked layout or shell layout)."""
|
|
info = await manager.get(sandbox_id) # raises SandboxNotFoundError -> 404
|
|
backend = manager._backend # noqa: SLF001 - API owns the composition seam
|
|
tracked = getattr(backend, "_tracked", {}).get(sandbox_id)
|
|
if tracked is not None:
|
|
return tracked.workspace, info
|
|
return info.workdir / "workspace", info
|
|
|
|
|
|
def _safe_rel_path(raw: str) -> Path:
|
|
"""Workspace-relative path; reject absolute/traversal paths."""
|
|
candidate = Path(raw)
|
|
if candidate.is_absolute() or ".." in candidate.parts:
|
|
raise HTTPException(status_code=422, detail=f"invalid workspace path {raw!r}")
|
|
return candidate
|
|
|
|
|
|
def _resolve_in_workspace(workspace: Path, rel: Path) -> Path:
|
|
"""Resolve `rel` under `workspace`, refusing symlink escapes (P7).
|
|
|
|
The lexical check in `_safe_rel_path` cannot see symlinks: an exec can
|
|
plant `ln -s /etc target` in the workspace and a follow-up read/write
|
|
would follow it OUT of the bind. Resolve with the workspace as the
|
|
anchor (strict: a symlink chain escaping raises) and confirm the
|
|
normalized target still sits inside the workspace — defense in depth
|
|
for both read_file and write_file.
|
|
"""
|
|
try:
|
|
target = (workspace / rel).resolve(strict=False)
|
|
target.relative_to(workspace.resolve(strict=False))
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=422, detail=f"path escapes the workspace: {rel.as_posix()!r}"
|
|
) from None
|
|
return target
|
|
|
|
|
|
@router.get("/{sandbox_id}/files")
|
|
async def list_files(
|
|
sandbox_id: str,
|
|
manager: SandboxManager = Depends(get_sandbox_manager),
|
|
) -> dict:
|
|
try:
|
|
workspace, _ = await _workspace_dir(manager, sandbox_id)
|
|
except SandboxNotFoundError:
|
|
raise HTTPException(status_code=404, detail=f"no sandbox {sandbox_id!r}") from None
|
|
return {"files": sorted(p.name for p in workspace.iterdir()) if workspace.is_dir() else []}
|
|
|
|
|
|
@router.get("/{sandbox_id}/files/{path:path}")
|
|
async def read_file(
|
|
sandbox_id: str,
|
|
path: str,
|
|
manager: SandboxManager = Depends(get_sandbox_manager),
|
|
) -> dict:
|
|
try:
|
|
workspace, _ = await _workspace_dir(manager, sandbox_id)
|
|
except SandboxNotFoundError:
|
|
raise HTTPException(status_code=404, detail=f"no sandbox {sandbox_id!r}") from None
|
|
rel = _safe_rel_path(path)
|
|
target = _resolve_in_workspace(workspace, rel)
|
|
if not target.is_file():
|
|
raise HTTPException(status_code=404, detail=f"no file {path!r}")
|
|
return {"path": path, "content": target.read_text(errors="replace")}
|
|
|
|
|
|
@router.put("/{sandbox_id}/files/{path:path}")
|
|
async def write_file(
|
|
sandbox_id: str,
|
|
path: str,
|
|
body: FileWriteRequest,
|
|
manager: SandboxManager = Depends(get_sandbox_manager),
|
|
) -> dict:
|
|
try:
|
|
workspace, _ = await _workspace_dir(manager, sandbox_id)
|
|
except SandboxNotFoundError:
|
|
raise HTTPException(status_code=404, detail=f"no sandbox {sandbox_id!r}") from None
|
|
rel = _safe_rel_path(body.path)
|
|
target = _resolve_in_workspace(workspace, rel)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(body.content)
|
|
return {"path": body.path, "written": True}
|
|
|
|
|
|
@router.post("/{sandbox_id}/exec", response_model=ExecResponse)
|
|
async def exec_command(
|
|
sandbox_id: str,
|
|
body: ExecRequest,
|
|
manager: SandboxManager = Depends(get_sandbox_manager),
|
|
) -> ExecResponse:
|
|
try:
|
|
await manager.get(sandbox_id)
|
|
except SandboxNotFoundError:
|
|
raise HTTPException(status_code=404, detail=f"no sandbox {sandbox_id!r}") from None
|
|
backend = manager._backend # noqa: SLF001 - API owns the composition seam
|
|
handle = manager._handles.get(sandbox_id) # noqa: SLF001
|
|
if handle is None:
|
|
raise HTTPException(status_code=404, detail=f"no live handle {sandbox_id!r}")
|
|
result = await backend.exec(handle, body.cmd)
|
|
return ExecResponse(**result.model_dump())
|