feat(delivery): docker sandbox adapter + owner-only infrastructure (v2 phase A3-1)

The execution boundary for v2. Adds the local docker/podman sandbox adapter
and the hardened host subprocess runner every other delivery adapter will use.

- adapters/process.py: the one place the platform shells out. argv-only (no
  shell), start_new_session + killpg on timeout, concurrent stdout/stderr
  drains with a byte cap, explicit minimal env (never os.environ), and a
  missing binary reported as spawn_failed rather than an exception.
- adapters/docker_sandbox.py: DockerSandboxAdapter over SandboxProviderPort.
  --user 1000:1000, --cap-drop ALL, no-new-privileges, pids/cpu/memory limits,
  --read-only with tmpfs scratch, one bind mount (the work tree, never the git
  dir), labels, no published ports. Path-safe file access; secrets refused in
  `docker exec -e`. Extras for later phases: start_demo_container (loopback
  publish only), list_managed_containers, remove_container.
- registry: SANDBOX_PROVIDER=docker, cached singleton so the workspace registry
  survives create_workspace -> start_session.
- sandbox_gateway worker: reap_orphans() removes labelled containers no live
  session points at, audited as sandbox.reap; run_pass() does both passes.
- sandbox router: every mutating route now requires the platform owner
  (PLAN-v2 §7.7); read-only status routes stay open to org members.
- sandbox-panel: canManageInfra prop (fails closed) hides the create form,
  the session panel and the action buttons from non-owners.

Tests: 346 API (+37), 67 web (+3); ruff, mypy, tsc and lint clean.
Three existing tests now drive the sandbox as the platform owner, since that
is who may touch it.

Report: docs/plan/reports/agent-3-phase-1.md
This commit is contained in:
mjalmoudy94
2026-10-13 23:57:09 +03:30
parent 0cd9194b64
commit a0c8473eb8
15 changed files with 2047 additions and 79 deletions
+1 -1
View File
@@ -86,7 +86,7 @@ def _sandbox_gateway_pass(args: argparse.Namespace):
gateway = SandboxGatewayWorker()
async def one_pass() -> WorkerOutcome:
return await gateway.reap(
return await gateway.run_pass(
idle_minutes=args.idle_minutes, limit=max(args.limit, 200)
)
+108 -2
View File
@@ -23,10 +23,19 @@ from datetime import timedelta
from app.application.container import build_sandbox_service
from app.application.ports.realtime import emit_sandbox_reaped
from app.core.config import settings
from app.domain.audit.models import AuditAction, AuditEvent
from app.domain.audit.models import (
AuditAction,
AuditEvent,
ErrorSeverity,
ErrorSource,
)
from app.domain.base import utcnow
from app.infrastructure.error_log import record_error
from app.infrastructure.repositories.audit import AuditRepository
from app.infrastructure.repositories.sandbox import WorkspaceRepository
from app.infrastructure.repositories.sandbox import (
SandboxSessionRepository,
WorkspaceRepository,
)
from app.workers.base import WorkerOutcome, worker_session
#: Sessions are considered abandoned after this long with no activity.
@@ -38,6 +47,22 @@ class SandboxGatewayWorker:
name = "sandbox-gateway"
async def run_pass(
self, *, idle_minutes: int = DEFAULT_IDLE_MINUTES, limit: int = 200
) -> WorkerOutcome:
"""One housekeeping pass: idle sessions first, then orphan containers.
Combined into one pass so a cron entry needs a single invocation and an
operator sees one summary line per run.
"""
outcome = await self.reap(idle_minutes=idle_minutes, limit=limit)
orphans = await self.reap_orphans(limit=limit)
outcome.processed += orphans.processed
outcome.failed += orphans.failed
for note in orphans.notes:
outcome.note(note)
return outcome
async def reap(
self, *, idle_minutes: int = DEFAULT_IDLE_MINUTES, limit: int = 200
) -> WorkerOutcome:
@@ -98,5 +123,86 @@ class SandboxGatewayWorker:
outcome.note("egress policy default = deny_all (set per resource limit)")
return outcome
async def reap_orphans(self, *, limit: int = 200) -> WorkerOutcome:
"""Remove labelled containers that no live session row points at.
``reap`` above stops *sessions*; this stops *containers*. The two are
not the same: a session row can be stopped (or the database restored
from a backup) while the container keeps running, and a container can be
started by a process that then died before writing its row. Without this
pass those containers hold CPU, memory and disk forever, because nothing
in the application knows they exist.
Only the container runtime is inspected. A provider without
``list_managed_containers`` (the mock, the remote adapters) has nothing
to reap and is skipped silently — this is not an error condition.
"""
outcome = WorkerOutcome(name=self.name)
from app.infrastructure.adapters.registry import get_sandbox_provider
provider = get_sandbox_provider()
lister = getattr(provider, "list_managed_containers", None)
if lister is None:
outcome.skipped += 1
outcome.note("provider does not expose managed containers; nothing to reap")
return outcome
try:
containers = await lister()
except Exception as exc: # noqa: BLE001 - a CLI outage must not kill the worker
await record_error(
kind="sandbox_orphan_list_failed",
message=str(exc),
exc=exc,
source=ErrorSource.WORKER,
severity=ErrorSeverity.WARNING,
metadata={"worker": self.name},
)
outcome.failed += 1
return outcome
async with worker_session() as session:
live = await SandboxSessionRepository(session).list_active_external_ids()
audit = AuditRepository(session)
reaped = 0
for container in containers:
if reaped >= limit:
break
if container.container_id in live:
continue
# A demo/build container is owned by the delivery pipeline, which
# tracks it in its own table. Until that table exists it is left
# alone rather than guessed at.
if container.role and container.role != "workspace":
outcome.skipped += 1
continue
removed = await provider.remove_container(container.container_id)
if not removed:
outcome.failed += 1
continue
reaped += 1
outcome.processed += 1
outcome.note(f"removed orphan container {container.container_id}")
await audit.record(
event=AuditEvent(
action=AuditAction.SANDBOX_REAP,
organization_id=container.organization_id or None,
target_type="sandbox_container",
target_id=container.container_id,
metadata_json={
"reason": "orphan_container",
"project_id": container.project_id,
"role": container.role,
},
)
)
if not reaped:
outcome.skipped += 1
return outcome
__all__ = ["DEFAULT_IDLE_MINUTES", "SandboxGatewayWorker"]