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:
@@ -21,6 +21,7 @@ import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.domain.base import utcnow
|
||||
from app.domain.identity.models import PlatformRole
|
||||
from app.workers import base as worker_base
|
||||
from app.workers.ai_runtime import AiRuntimeWorker
|
||||
from app.workers.billing import BillingWorker
|
||||
@@ -48,9 +49,18 @@ async def _workers_use_the_test_database(monkeypatch, session_factory):
|
||||
return session_factory
|
||||
|
||||
|
||||
async def _make_org_with_employee(client: AsyncClient, email: str) -> tuple[str, dict, str, str]:
|
||||
"""Return ``(org_id, headers, project_id, employee_id)`` for a fresh tenant."""
|
||||
token = (await signup(client, email))["access_token"]
|
||||
async def _make_org_with_employee(
|
||||
client: AsyncClient,
|
||||
email: str,
|
||||
*,
|
||||
platform_role: PlatformRole = PlatformRole.MANAGER,
|
||||
) -> tuple[str, dict, str, str]:
|
||||
"""Return ``(org_id, headers, project_id, employee_id)`` for a fresh tenant.
|
||||
|
||||
``platform_role`` is a parameter because the sandbox-gateway tests drive
|
||||
container endpoints, which v2 restricts to the platform owner.
|
||||
"""
|
||||
token = (await signup(client, email, platform_role=platform_role))["access_token"]
|
||||
headers = auth_header(token)
|
||||
org_id = (
|
||||
await client.post("/api/organizations", json={"name": "سازمان کارگر"}, headers=headers)
|
||||
@@ -249,7 +259,7 @@ async def test_billing_roll_up_refuses_a_nonsense_period(delta: timedelta):
|
||||
async def test_sandbox_gateway_stops_an_idle_session_and_audits_it(client: AsyncClient):
|
||||
"""An abandoned session must be stopped, and the stop must be auditable."""
|
||||
org_id, headers, project_id, _ = await _make_org_with_employee(
|
||||
client, "worker-gateway@example.com"
|
||||
client, "worker-gateway@example.com", platform_role=PlatformRole.OWNER
|
||||
)
|
||||
template_id = (
|
||||
await client.get(f"/api/organizations/{org_id}/sandbox/templates", headers=headers)
|
||||
@@ -301,7 +311,7 @@ async def test_sandbox_gateway_stops_an_idle_session_and_audits_it(client: Async
|
||||
async def test_sandbox_gateway_leaves_a_fresh_session_alone(client: AsyncClient):
|
||||
"""A session that was just used must not be reaped."""
|
||||
org_id, headers, project_id, _ = await _make_org_with_employee(
|
||||
client, "worker-gateway-fresh@example.com"
|
||||
client, "worker-gateway-fresh@example.com", platform_role=PlatformRole.OWNER
|
||||
)
|
||||
template_id = (
|
||||
await client.get(f"/api/organizations/{org_id}/sandbox/templates", headers=headers)
|
||||
|
||||
Reference in New Issue
Block a user