From a0c8473eb891bc6dfd1f098676389e9dc4c43a67 Mon Sep 17 00:00:00 2001
From: mjalmoudy94
Date: Tue, 13 Oct 2026 23:57:09 +0330
Subject: [PATCH] feat(delivery): docker sandbox adapter + owner-only
infrastructure (v2 phase A3-1)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.gitignore | 3 +
.../infrastructure/adapters/docker_sandbox.py | 641 ++++++++++++++++++
.../app/infrastructure/adapters/process.py | 274 ++++++++
.../app/infrastructure/adapters/registry.py | 10 +-
.../infrastructure/repositories/sandbox.py | 23 +
.../app/interfaces/http/routers/sandbox.py | 48 +-
apps/api/app/workers/__main__.py | 2 +-
apps/api/app/workers/sandbox_gateway.py | 110 ++-
apps/api/tests/test_docker_sandbox.py | 588 ++++++++++++++++
apps/api/tests/test_e2e_smoke.py | 10 +-
apps/api/tests/test_workers.py | 20 +-
apps/web/src/app/(app)/projects/[id]/page.tsx | 8 +-
apps/web/src/components/sandbox-panel.tsx | 133 ++--
.../src/test/delivery-sandbox-panel.test.tsx | 114 ++++
docs/plan/reports/agent-3-phase-1.md | 142 ++++
15 files changed, 2047 insertions(+), 79 deletions(-)
create mode 100644 apps/api/app/infrastructure/adapters/docker_sandbox.py
create mode 100644 apps/api/app/infrastructure/adapters/process.py
create mode 100644 apps/api/tests/test_docker_sandbox.py
create mode 100644 apps/web/src/test/delivery-sandbox-panel.test.tsx
create mode 100644 docs/plan/reports/agent-3-phase-1.md
diff --git a/.gitignore b/.gitignore
index 7f18480..ec0fd73 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,3 +57,6 @@ htmlcov/
# v2 runtime data (workspaces, artifacts)
.data/
+
+# pytest scratch root, used when the system temp dir is not writable
+.pytest-tmp/
diff --git a/apps/api/app/infrastructure/adapters/docker_sandbox.py b/apps/api/app/infrastructure/adapters/docker_sandbox.py
new file mode 100644
index 0000000..40754b3
--- /dev/null
+++ b/apps/api/app/infrastructure/adapters/docker_sandbox.py
@@ -0,0 +1,641 @@
+"""Local Docker/podman sandbox adapter — the hardened execution boundary (D5).
+
+One *workspace* container per application is where the AI employees edit files
+and run commands; a separate *demo* container (created by the delivery service)
+runs the latest green build. Both are created by this adapter.
+
+The hardening is not decoration — it is the only thing standing between an LLM
+that writes arbitrary shell and the host:
+
+* non-root (``--user 1000:1000``) and ``--cap-drop ALL`` + ``no-new-privileges``
+ so a setuid binary cannot be used to escalate;
+* ``--read-only`` root filesystem with two ``tmpfs`` scratch mounts, so a
+ compromised process cannot persist anything outside the bind-mounted work dir;
+* ``--pids-limit`` / ``--cpus`` / ``--memory`` so a fork bomb or a runaway build
+ cannot take the host down;
+* the **git dir is never mounted** — only the work tree. The platform runs git
+ on the host (see ``git_workspace.py``), so repository credentials never enter
+ the container;
+* ports are published on ``127.0.0.1`` only, never ``0.0.0.0``.
+
+The provider binary is configurable (``SANDBOX_DOCKER_BIN``) and the flags used
+here are the common subset of ``docker`` and ``podman``, so podman works as a
+drop-in.
+
+``create_workspace``/``start_session`` are split by the port, but the workspace
+identity is *encoded in the external id* (``ws--``) so a restart
+can rebuild the host paths without a database round-trip. Only the image is
+kept in a process-local registry; a cold registry falls back to the profile
+default for the language, which is what a fresh process would want anyway.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import logging
+import os
+import re
+import shutil
+from collections.abc import Awaitable, Callable, Sequence
+from dataclasses import dataclass, field
+
+from app.application.ports import (
+ CommandResult,
+ SandboxProviderPort, # noqa: F401 - documented as the implemented protocol
+ SessionInfo,
+ SnapshotInfo,
+ WorkspaceInfo,
+)
+from app.core.config import settings
+from app.core.errors import ValidationError
+from app.domain.platform.rules import assert_not_protected_port
+from app.infrastructure.adapters.process import ProcessResult, run_process
+
+logger = logging.getLogger("agy.sandbox.docker")
+
+#: Language → base image. The plan fixes these names; the mapping lives here so
+#: the delivery service can ask for an image without knowing the table.
+LANGUAGE_IMAGES: dict[str, str] = {
+ "node": "node:20-bookworm",
+ "javascript": "node:20-bookworm",
+ "typescript": "node:20-bookworm",
+ "python": "python:3.13-bookworm",
+ "php": "php:8.3-cli",
+ "go": "golang:1.23",
+ "golang": "golang:1.23",
+ "static": "debian:bookworm",
+ "html": "debian:bookworm",
+}
+FALLBACK_IMAGE = "debian:bookworm"
+
+#: Role → label value. The reaper and the admin console filter on these.
+ROLE_WORKSPACE = "workspace"
+ROLE_DEMO = "demo"
+ROLE_BUILD = "build"
+
+LABEL_ORG = "smart-org.org"
+LABEL_PROJECT = "smart-org.project"
+LABEL_ROLE = "smart-org.role"
+
+#: In-container mount point of the working tree.
+SANDBOX_WORKSPACE_PATH = "/workspace"
+
+_SAFE_ID = re.compile(r"^[A-Za-z0-9_.-]+$")
+_SNAPSHOT_NAME = re.compile(r"[^a-z0-9-]+")
+
+#: Command used to keep a container alive. ``sleep infinity`` is present in
+#: every image we use (coreutils in debian, busybox in alpine variants).
+_KEEPALIVE = "sleep infinity"
+
+
+class PathEscapeError(ValidationError):
+ """A requested path resolves outside the sandbox working directory."""
+
+
+def image_for_language(language: str | None) -> str:
+ """Base image for an app profile language, with a safe fallback."""
+ return LANGUAGE_IMAGES.get((language or "").strip().lower(), FALLBACK_IMAGE)
+
+
+def container_name(role: str, organization_id: str, project_id: str) -> str:
+ """Deterministic container name so a restart can find its own containers."""
+ safe = f"smartorg-{role}-{organization_id}-{project_id}".lower()
+ return re.sub(r"[^a-z0-9_.-]", "-", safe)[:200]
+
+
+def _check_id(value: str, label: str) -> str:
+ clean = (value or "").strip()
+ if not clean or not _SAFE_ID.match(clean):
+ raise ValidationError(f"شناسهٔ {label} نامعتبر است.")
+ return clean
+
+
+def safe_join(root: str, path: str) -> str:
+ """Resolve ``path`` under ``root``, refusing any escape.
+
+ ``..`` segments, absolute paths and symlinks that point outside the root are
+ all rejected after ``realpath``, so a symlink created inside the sandbox
+ cannot be used to read ``/etc/shadow`` on the host.
+ """
+ root_real = os.path.realpath(root)
+ relative = (path or "").lstrip("/")
+ candidate = os.path.realpath(os.path.join(root_real, relative))
+ if candidate != root_real and not candidate.startswith(root_real + os.sep):
+ raise PathEscapeError("مسیر درخواستی خارج از محیط اجرا است.")
+ return candidate
+
+
+@dataclass(slots=True)
+class ManagedContainer:
+ """One container carrying our labels, as reported by the CLI."""
+
+ container_id: str
+ name: str = ""
+ organization_id: str = ""
+ project_id: str = ""
+ role: str = ""
+ status: str = ""
+
+
+@dataclass(slots=True)
+class _WorkspaceState:
+ organization_id: str
+ project_id: str
+ host_dir: str
+ image: str
+ session_ids: list[str] = field(default_factory=list)
+
+
+class DockerSandboxAdapter:
+ """``SandboxProviderPort`` over the docker/podman CLI.
+
+ ``runner`` is injectable so unit tests assert on the exact argv without a
+ container runtime present.
+ """
+
+ def __init__(
+ self,
+ *,
+ provider_bin: str | None = None,
+ workspaces_root: str | None = None,
+ default_cpus: float | None = None,
+ default_memory_mb: int | None = None,
+ max_output_bytes: int | None = None,
+ runner: Callable[..., Awaitable[ProcessResult]] = run_process,
+ ) -> None:
+ self.bin = (provider_bin or settings.sandbox_docker_bin or "docker").strip()
+ self.workspaces_root = os.path.abspath(
+ workspaces_root or settings.sandbox_workspaces_root
+ )
+ self.default_cpus = float(
+ settings.sandbox_default_cpus if default_cpus is None else default_cpus
+ )
+ self.default_memory_mb = int(
+ settings.sandbox_default_memory_mb if default_memory_mb is None else default_memory_mb
+ )
+ self.max_output_bytes = int(
+ settings.sandbox_max_output_bytes if max_output_bytes is None else max_output_bytes
+ )
+ self._runner = runner
+ self._workspaces: dict[str, _WorkspaceState] = {}
+ #: container id → the workspace state it belongs to (filled by start_session).
+ self._sessions: dict[str, _WorkspaceState] = {}
+
+ # -- helpers -------------------------------------------------------------
+
+ def _workspace_dir(self, organization_id: str, project_id: str) -> str:
+ return os.path.join(self.workspaces_root, organization_id, project_id, "work")
+
+ def _external_id(self, organization_id: str, project_id: str) -> str:
+ return f"ws-{organization_id}-{project_id}"
+
+ @staticmethod
+ def _parse_external_id(external_id: str) -> tuple[str, str] | None:
+ if not external_id.startswith("ws-"):
+ return None
+ parts = external_id[3:].split("-", 1)
+ if len(parts) != 2:
+ return None
+ return parts[0], parts[1]
+
+ def _state_for(self, external_id: str, image: str | None = None) -> _WorkspaceState:
+ """Look up (or rebuild) the workspace state behind an external id."""
+ state = self._workspaces.get(external_id)
+ if state is not None:
+ return state
+ parsed = self._parse_external_id(external_id)
+ if parsed is None:
+ raise ValidationError("شناسهٔ محیط اجرا نامعتبر است.")
+ organization_id, project_id = parsed
+ state = _WorkspaceState(
+ organization_id=organization_id,
+ project_id=project_id,
+ host_dir=self._workspace_dir(organization_id, project_id),
+ image=image or FALLBACK_IMAGE,
+ )
+ self._workspaces[external_id] = state
+ return state
+
+ def _state_for_session(self, session_external_id: str) -> _WorkspaceState | None:
+ return self._sessions.get(session_external_id)
+
+ async def _docker(
+ self,
+ args: Sequence[str],
+ *,
+ timeout_seconds: float = 120.0,
+ on_output: Callable[[str], Awaitable[None]] | None = None,
+ ) -> ProcessResult:
+ return await self._runner(
+ [self.bin, *args],
+ timeout_seconds=timeout_seconds,
+ max_output_bytes=self.max_output_bytes,
+ on_output=on_output,
+ env={},
+ )
+
+ # -- workspaces ----------------------------------------------------------
+
+ async def create_workspace(
+ self, *, organization_id: str, project_id: str, template_id: str, image: str
+ ) -> WorkspaceInfo:
+ org = _check_id(organization_id, "سازمان")
+ project = _check_id(project_id, "پروژه")
+
+ host_dir = self._workspace_dir(org, project)
+ # 0700: the workspace holds the tenant's source code. The platform user
+ # owns it; nothing else on the host may read it.
+ os.makedirs(host_dir, exist_ok=True)
+ with contextlib.suppress(OSError):
+ os.chmod(self.workspaces_root, 0o700)
+ os.chmod(os.path.dirname(host_dir), 0o700)
+ os.chmod(host_dir, 0o700)
+
+ external_id = self._external_id(org, project)
+ self._workspaces[external_id] = _WorkspaceState(
+ organization_id=org,
+ project_id=project,
+ host_dir=host_dir,
+ image=image or FALLBACK_IMAGE,
+ )
+ return WorkspaceInfo(
+ external_id=external_id,
+ status="ready",
+ raw={"host_dir": host_dir, "image": image},
+ )
+
+ async def start_session(self, *, workspace_external_id: str) -> SessionInfo:
+ state = self._state_for(workspace_external_id)
+ name = container_name(ROLE_WORKSPACE, state.organization_id, state.project_id)
+
+ # A restart leaves the old container behind; reuse it rather than
+ # stacking duplicates, and start it if it was stopped.
+ existing = await self._container_id_by_name(name)
+ if existing:
+ await self._docker(["start", existing], timeout_seconds=60)
+ self._sessions[existing] = state
+ state.session_ids.append(existing)
+ return SessionInfo(external_id=existing, status="running")
+
+ result = await self._docker(
+ [
+ "run",
+ "-d",
+ "--name",
+ name,
+ # --- isolation -------------------------------------------------
+ "--user",
+ "1000:1000",
+ "--cap-drop",
+ "ALL",
+ "--security-opt",
+ "no-new-privileges",
+ "--pids-limit",
+ "512",
+ "--cpus",
+ str(self.default_cpus),
+ "--memory",
+ f"{self.default_memory_mb}m",
+ # --- immutable root, scratch in tmpfs --------------------------
+ "--read-only",
+ "--tmpfs",
+ "/tmp",
+ "--tmpfs",
+ "/home/app",
+ # --- only the work tree, never the git dir ---------------------
+ "-v",
+ f"{state.host_dir}:{SANDBOX_WORKSPACE_PATH}",
+ "-w",
+ SANDBOX_WORKSPACE_PATH,
+ "--label",
+ f"{LABEL_ORG}={state.organization_id}",
+ "--label",
+ f"{LABEL_PROJECT}={state.project_id}",
+ "--label",
+ f"{LABEL_ROLE}={ROLE_WORKSPACE}",
+ # Default bridge network: package installs need egress. No
+ # published ports — the container is reached with ``exec``.
+ state.image,
+ "sh",
+ "-lc",
+ _KEEPALIVE,
+ ],
+ timeout_seconds=180,
+ )
+ if not result.ok:
+ raise ValidationError("راهاندازی محیط اجرا ناموفق بود.")
+
+ container_id = result.stdout.strip().splitlines()[-1].strip() if result.stdout else ""
+ if not container_id:
+ raise ValidationError("راهاندازی محیط اجرا ناموفق بود.")
+ self._sessions[container_id] = state
+ state.session_ids.append(container_id)
+ return SessionInfo(
+ external_id=container_id,
+ status="running",
+ raw={"name": name, "image": state.image},
+ )
+
+ async def _container_id_by_name(self, name: str) -> str | None:
+ result = await self._docker(
+ ["ps", "-a", "--filter", f"name=^{name}$", "--format", "{{.ID}}"],
+ timeout_seconds=30,
+ )
+ if not result.ok or not result.stdout.strip():
+ return None
+ return result.stdout.strip().splitlines()[0].strip()
+
+ # -- commands ------------------------------------------------------------
+
+ async def run_command(
+ self,
+ *,
+ session_external_id: str,
+ command: str,
+ timeout_seconds: int,
+ max_output_bytes: int,
+ env: dict[str, str] | None = None,
+ cwd: str | None = None,
+ on_output: Callable[[str], Awaitable[None]] | None = None,
+ ) -> CommandResult:
+ """``docker exec`` the command inside the container.
+
+ ``cwd``/``on_output`` are adapter-level extras used by the engine; the
+ port signature is unchanged.
+
+ ``env`` is passed with ``-e``. Keys that *look* like secrets are refused
+ outright: argv is visible in ``ps`` and in the audit log, and the whole
+ point of the design is that credentials never travel that way.
+ """
+ cleaned = (command or "").strip()
+ if not cleaned:
+ raise ValidationError("دستور نمیتواند خالی باشد.")
+
+ env_flags: list[str] = []
+ for key, value in (env or {}).items():
+ if _looks_like_secret(key):
+ raise ValidationError(
+ "متغیرهای محیطی حاوی اطلاعات محرمانه مجاز نیستند."
+ )
+ env_flags.extend(["-e", f"{key}={value}"])
+
+ workdir = cwd or SANDBOX_WORKSPACE_PATH
+ args = [
+ "exec",
+ "-w",
+ workdir,
+ *env_flags,
+ session_external_id,
+ "sh",
+ "-lc",
+ cleaned,
+ ]
+ result = await self._runner(
+ [self.bin, *args],
+ timeout_seconds=float(timeout_seconds),
+ max_output_bytes=int(max_output_bytes),
+ on_output=on_output,
+ env={},
+ )
+ return CommandResult(
+ exit_code=result.exit_code,
+ stdout=result.stdout,
+ stderr=result.stderr,
+ duration_ms=result.duration_ms,
+ truncated=result.truncated,
+ )
+
+ # -- files ---------------------------------------------------------------
+
+ def _host_path(self, session_external_id: str, path: str) -> str:
+ state = self._state_for_session(session_external_id)
+ if state is None:
+ raise ValidationError("نشست اجرا یافت نشد.")
+ # Container paths under /workspace map 1:1 onto the bind-mounted host
+ # dir; anything else is not reachable through this adapter.
+ relative = (path or "").lstrip("/")
+ if relative.startswith("workspace"):
+ relative = relative[len("workspace") :].lstrip("/")
+ return safe_join(state.host_dir, relative)
+
+ async def read_file(self, *, session_external_id: str, path: str) -> str:
+ host_path = self._host_path(session_external_id, path)
+ if not os.path.isfile(host_path):
+ return ""
+ with open(host_path, encoding="utf-8", errors="replace") as handle:
+ return handle.read()
+
+ async def write_file(self, *, session_external_id: str, path: str, content: str) -> None:
+ host_path = self._host_path(session_external_id, path)
+ os.makedirs(os.path.dirname(host_path) or ".", exist_ok=True)
+ with open(host_path, "w", encoding="utf-8") as handle:
+ handle.write(content)
+
+ # -- lifecycle -----------------------------------------------------------
+
+ async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo:
+ """``docker commit`` the container.
+
+ Supported, but documented as a heavy operation: it captures the whole
+ root filesystem, and the work tree lives on a bind mount that is *not*
+ included. Snapshots are therefore useful for the base image state only.
+ """
+ state = self._state_for_session(session_external_id)
+ slug = _SNAPSHOT_NAME.sub("-", (name or "snapshot").lower()).strip("-") or "snapshot"
+ org = state.organization_id if state else "unknown"
+ tag = f"smartorg-snapshot-{org}-{slug}"[:120]
+ result = await self._docker(
+ ["commit", session_external_id, tag], timeout_seconds=300
+ )
+ if not result.ok:
+ raise ValidationError("تهیهٔ اسنپشات ناموفق بود.")
+ return SnapshotInfo(external_id=tag, name=name)
+
+ async def pause(self, *, session_external_id: str) -> None:
+ await self._docker(["stop", session_external_id], timeout_seconds=60)
+
+ async def resume(self, *, session_external_id: str) -> SessionInfo:
+ result = await self._docker(["start", session_external_id], timeout_seconds=60)
+ return SessionInfo(
+ external_id=session_external_id,
+ status="running" if result.ok else "error",
+ )
+
+ async def destroy(self, *, session_external_id: str) -> None:
+ # ``-f`` covers running and stopped; ``-v`` removes anonymous volumes so
+ # a destroyed workspace does not leave data behind on the host.
+ await self._docker(["rm", "-f", "-v", session_external_id], timeout_seconds=60)
+ self._sessions.pop(session_external_id, None)
+
+ async def get_preview_url(self, *, session_external_id: str, port: int) -> str:
+ # Loopback only, and never an internal platform port. The public hostname
+ # is a domain-layer concern (Phase 4), not the sandbox's.
+ assert_not_protected_port(int(port))
+ return f"http://127.0.0.1:{int(port)}"
+
+ # -- demo / build containers (used by the delivery service) --------------
+
+ async def start_demo_container(
+ self,
+ *,
+ organization_id: str,
+ project_id: str,
+ image: str,
+ host_port: int,
+ app_port: int,
+ command: str,
+ work_dir: str,
+ cpus: float | None = None,
+ memory_mb: int | None = None,
+ name_suffix: str = "",
+ ) -> str:
+ """Start a demo container and return its id.
+
+ Blue/green is implemented by the caller: start on a temporary port, wait
+ for the healthcheck, switch the route, then stop the previous container.
+ Publishing on ``127.0.0.1`` means only Caddy on this host can reach it.
+ """
+ org = _check_id(organization_id, "سازمان")
+ project = _check_id(project_id, "پروژه")
+ assert_not_protected_port(int(host_port))
+ name = container_name(ROLE_DEMO, org, project)
+ if name_suffix:
+ name = f"{name}-{_SNAPSHOT_NAME.sub('-', name_suffix.lower()).strip('-')}"[:200]
+
+ result = await self._docker(
+ [
+ "run",
+ "-d",
+ "--name",
+ name,
+ "--user",
+ "1000:1000",
+ "--cap-drop",
+ "ALL",
+ "--security-opt",
+ "no-new-privileges",
+ "--pids-limit",
+ "512",
+ "--cpus",
+ str(cpus if cpus is not None else self.default_cpus),
+ "--memory",
+ f"{int(memory_mb if memory_mb is not None else self.default_memory_mb)}m",
+ "--read-only",
+ "--tmpfs",
+ "/tmp",
+ "--tmpfs",
+ "/home/app",
+ "-v",
+ f"{work_dir}:{SANDBOX_WORKSPACE_PATH}",
+ "-w",
+ SANDBOX_WORKSPACE_PATH,
+ # Loopback publish only — never 0.0.0.0.
+ "-p",
+ f"127.0.0.1:{int(host_port)}:{int(app_port)}",
+ "--label",
+ f"{LABEL_ORG}={org}",
+ "--label",
+ f"{LABEL_PROJECT}={project}",
+ "--label",
+ f"{LABEL_ROLE}={ROLE_DEMO}",
+ image,
+ "sh",
+ "-lc",
+ command or _KEEPALIVE,
+ ],
+ timeout_seconds=180,
+ )
+ if not result.ok or not result.stdout.strip():
+ raise ValidationError("راهاندازی نسخهٔ نمایشی ناموفق بود.")
+ return result.stdout.strip().splitlines()[-1].strip()
+
+ # -- maintenance ---------------------------------------------------------
+
+ async def list_managed_containers(self) -> list[ManagedContainer]:
+ """Every container carrying our labels, running or not.
+
+ Used by the sandbox-gateway worker to find orphans: a container whose
+ session row is gone is still holding CPU and disk.
+ """
+ result = await self._docker(
+ [
+ "ps",
+ "-a",
+ "--filter",
+ f"label={LABEL_ROLE}",
+ "--format",
+ "{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Labels}}",
+ ],
+ timeout_seconds=30,
+ )
+ if not result.ok:
+ return []
+
+ containers: list[ManagedContainer] = []
+ for line in result.stdout.splitlines():
+ parts = line.split("\t")
+ if len(parts) < 4:
+ continue
+ labels = _parse_labels(parts[3])
+ containers.append(
+ ManagedContainer(
+ container_id=parts[0].strip(),
+ name=parts[1].strip(),
+ status=parts[2].strip(),
+ organization_id=labels.get(LABEL_ORG, ""),
+ project_id=labels.get(LABEL_PROJECT, ""),
+ role=labels.get(LABEL_ROLE, ""),
+ )
+ )
+ return containers
+
+ async def remove_container(self, container_id: str) -> bool:
+ result = await self._docker(["rm", "-f", "-v", container_id], timeout_seconds=60)
+ self._sessions.pop(container_id, None)
+ return result.ok
+
+ def host_dir_for(self, organization_id: str, project_id: str) -> str:
+ """Absolute host path of a project's working tree."""
+ return self._workspace_dir(
+ _check_id(organization_id, "سازمان"), _check_id(project_id, "پروژه")
+ )
+
+ @staticmethod
+ def binary_available(binary: str | None = None) -> bool:
+ """True when the container runtime CLI is on PATH."""
+ return shutil.which((binary or settings.sandbox_docker_bin or "docker").strip()) is not None
+
+
+def _parse_labels(raw: str) -> dict[str, str]:
+ labels: dict[str, str] = {}
+ for item in (raw or "").split(","):
+ if "=" not in item:
+ continue
+ key, _, value = item.partition("=")
+ labels[key.strip()] = value.strip()
+ return labels
+
+
+def _looks_like_secret(key: str) -> bool:
+ from app.core.redact import is_sensitive_key
+
+ return is_sensitive_key(key)
+
+
+__all__ = [
+ "FALLBACK_IMAGE",
+ "LABEL_ORG",
+ "LABEL_PROJECT",
+ "LABEL_ROLE",
+ "LANGUAGE_IMAGES",
+ "ROLE_BUILD",
+ "ROLE_DEMO",
+ "ROLE_WORKSPACE",
+ "SANDBOX_WORKSPACE_PATH",
+ "DockerSandboxAdapter",
+ "ManagedContainer",
+ "PathEscapeError",
+ "container_name",
+ "image_for_language",
+ "safe_join",
+]
diff --git a/apps/api/app/infrastructure/adapters/process.py b/apps/api/app/infrastructure/adapters/process.py
new file mode 100644
index 0000000..0906746
--- /dev/null
+++ b/apps/api/app/infrastructure/adapters/process.py
@@ -0,0 +1,274 @@
+"""The one place the platform shells out to a host binary.
+
+Nothing else in the codebase may call ``subprocess`` or ``os.system``. Every
+adapter that needs an external binary (docker/podman, git, caddy reload) goes
+through :func:`run_process`, which exists to make four failure modes impossible
+rather than merely unlikely:
+
+**Shell injection.** The command is an ``argv`` sequence handed straight to
+``asyncio.create_subprocess_exec``. There is no ``shell=True`` path and no
+string interpolation into a shell, so a task id or a branch name can never
+become a second command.
+
+**Orphaned children.** The child starts in its own session
+(``start_new_session=True``), so a timeout kills the whole *process group*. A
+``docker build`` that spawns helpers, or a ``git`` that runs a credential
+helper, dies with its parent instead of lingering as a stray process holding a
+lock.
+
+**Pipe deadlock.** stdout and stderr are drained concurrently by two tasks.
+Reading one to completion before touching the other is the classic 64 KB
+deadlock: the child blocks writing stderr, we block reading stdout, and both
+wait forever. ``communicate()`` is not used because we want a byte cap and a
+streaming callback.
+
+**Secret inheritance.** The child gets an explicit, minimal environment built
+from :func:`base_env`, never ``os.environ`` wholesale. The platform process
+holds the JWT secret, the database DSN and every provider key; a subprocess that
+inherits them can leak them through a stack trace, a build log or a core dump.
+
+Output is capped in bytes while streaming, so a runaway build cannot exhaust
+memory. The cap is applied *after* the callback sees the chunk, so live logs
+still show everything that arrived.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import os
+import signal
+import time
+from collections.abc import Awaitable, Callable, Mapping, Sequence
+from dataclasses import dataclass, field
+
+#: Environment variables a child may inherit from the platform process. Anything
+#: not listed is dropped. Deliberately tiny.
+_BASE_ENV_KEYS: tuple[str, ...] = ("PATH", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR")
+
+#: Fallback PATH when the parent has none (systemd units often do not).
+_FALLBACK_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
+
+#: Read size per pipe chunk. 64 KiB is the pipe buffer size, so this drains
+#: promptly without spinning.
+_CHUNK_BYTES = 65_536
+
+#: How long a child gets to die politely after SIGTERM before SIGKILL.
+_TERM_GRACE_SECONDS = 5.0
+
+#: An argv element longer than this is almost certainly a mistake (a base64 blob
+#: pasted into a command line). Refused rather than silently truncated.
+MAX_ARGV_ELEMENT_CHARS = 100_000
+
+
+class ProcessError(RuntimeError):
+ """A host command could not be started at all (binary missing, not executable)."""
+
+
+@dataclass(slots=True)
+class ProcessResult:
+ """Outcome of one host command.
+
+ ``argv`` is retained so a test can assert on the *exact* argument vector —
+ which is how the hardening flags and the "no credential in argv" rule are
+ verified.
+ """
+
+ argv: tuple[str, ...]
+ exit_code: int
+ stdout: str = ""
+ stderr: str = ""
+ duration_ms: int = 0
+ timed_out: bool = False
+ truncated: bool = False
+ #: True when the binary itself could not be executed (ENOENT/EACCES).
+ spawn_failed: bool = False
+ spawn_error: str = ""
+
+ @property
+ def ok(self) -> bool:
+ return self.exit_code == 0 and not self.timed_out and not self.spawn_failed
+
+
+@dataclass(slots=True)
+class _DrainResult:
+ data: bytes = b""
+ truncated: bool = False
+ lines: list[str] = field(default_factory=list)
+
+
+def base_env(extra: Mapping[str, str] | None = None) -> dict[str, str]:
+ """Minimal child environment: a few safe keys plus explicit overrides.
+
+ Callers add what they need (``HOME`` for git, ``GIT_CONFIG_NOSYSTEM=1``) and
+ nothing else is inherited.
+ """
+ env = {key: os.environ[key] for key in _BASE_ENV_KEYS if key in os.environ}
+ env.setdefault("PATH", _FALLBACK_PATH)
+ if extra:
+ env.update({str(k): str(v) for k, v in extra.items()})
+ return env
+
+
+def _validate_argv(argv: Sequence[str]) -> tuple[str, ...]:
+ if not argv:
+ raise ValueError("argv must not be empty")
+ cleaned: list[str] = []
+ for index, element in enumerate(argv):
+ if not isinstance(element, str):
+ raise TypeError(f"argv[{index}] must be str, got {type(element).__name__}")
+ if "\x00" in element:
+ raise ValueError(f"argv[{index}] contains a NUL byte")
+ if len(element) > MAX_ARGV_ELEMENT_CHARS:
+ raise ValueError(
+ f"argv[{index}] is {len(element)} chars; "
+ f"the limit is {MAX_ARGV_ELEMENT_CHARS}"
+ )
+ cleaned.append(element)
+ return tuple(cleaned)
+
+
+async def _drain(
+ stream: asyncio.StreamReader | None,
+ *,
+ cap_bytes: int,
+ on_output: Callable[[str], Awaitable[None]] | None,
+) -> _DrainResult:
+ """Read a pipe to EOF, keeping at most ``cap_bytes`` and streaming every chunk."""
+ result = _DrainResult()
+ if stream is None:
+ return result
+
+ buffer = bytearray()
+ truncated = False
+ while True:
+ chunk = await stream.read(_CHUNK_BYTES)
+ if not chunk:
+ break
+ if len(buffer) < cap_bytes:
+ remaining = cap_bytes - len(buffer)
+ buffer.extend(chunk[:remaining])
+ if len(chunk) > remaining:
+ truncated = True
+ else:
+ truncated = True
+ if on_output is not None:
+ # The callback sees the *raw* chunk even past the cap: a live log
+ # should not stop scrolling just because we stopped storing.
+ with contextlib.suppress(Exception):
+ await on_output(chunk.decode("utf-8", "replace"))
+
+ result.data = bytes(buffer)
+ result.truncated = truncated
+ return result
+
+
+async def _terminate(proc: asyncio.subprocess.Process) -> None:
+ """SIGTERM the child's process group, then SIGKILL if it will not go."""
+ if proc.returncode is not None:
+ return
+
+ def _signal_group(sig: int) -> None:
+ # The child is a session leader (``start_new_session=True``), so its pid
+ # *is* its process-group id. Fall back to the pid on platforms without
+ # process groups.
+ with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
+ if hasattr(os, "killpg"):
+ os.killpg(proc.pid, sig)
+ else: # pragma: no cover - POSIX is the only supported target
+ proc.send_signal(sig)
+
+ _signal_group(signal.SIGTERM)
+ try:
+ await asyncio.wait_for(proc.wait(), timeout=_TERM_GRACE_SECONDS)
+ return
+ except TimeoutError:
+ pass
+ _signal_group(signal.SIGKILL)
+ with contextlib.suppress(Exception):
+ await proc.wait()
+
+
+async def run_process(
+ argv: Sequence[str],
+ *,
+ cwd: str | None = None,
+ env: Mapping[str, str] | None = None,
+ timeout_seconds: float = 120.0,
+ max_output_bytes: int = 200_000,
+ on_output: Callable[[str], Awaitable[None]] | None = None,
+ stdin_data: bytes | None = None,
+) -> ProcessResult:
+ """Run ``argv`` and return its bounded, timed result.
+
+ Never raises for a non-zero exit or a timeout — those are ordinary outcomes
+ the caller inspects. It *does* raise :class:`ProcessError` when the binary
+ cannot be started, because that is a deployment fault, not a command result.
+
+ ``stdin_data`` is written and closed immediately; the platform never hands a
+ child an interactive terminal.
+ """
+ cleaned = _validate_argv(argv)
+ started = time.perf_counter()
+
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ *cleaned,
+ cwd=cwd,
+ env=base_env(env),
+ stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ start_new_session=True,
+ )
+ except (FileNotFoundError, NotADirectoryError, PermissionError, OSError) as exc:
+ return ProcessResult(
+ argv=cleaned,
+ exit_code=127,
+ stderr=str(exc),
+ duration_ms=int((time.perf_counter() - started) * 1000),
+ spawn_failed=True,
+ spawn_error=type(exc).__name__,
+ )
+
+ if stdin_data is not None and proc.stdin is not None:
+ with contextlib.suppress(BrokenPipeError, ConnectionResetError):
+ proc.stdin.write(stdin_data)
+ await proc.stdin.drain()
+ with contextlib.suppress(Exception):
+ proc.stdin.close()
+
+ stdout_task = asyncio.create_task(
+ _drain(proc.stdout, cap_bytes=max_output_bytes, on_output=on_output)
+ )
+ stderr_task = asyncio.create_task(
+ _drain(proc.stderr, cap_bytes=max_output_bytes, on_output=None)
+ )
+
+ timed_out = False
+ try:
+ await asyncio.wait_for(proc.wait(), timeout=max(1.0, float(timeout_seconds)))
+ except TimeoutError:
+ timed_out = True
+ await _terminate(proc)
+
+ stdout_result, stderr_result = await asyncio.gather(stdout_task, stderr_task)
+
+ return ProcessResult(
+ argv=cleaned,
+ exit_code=proc.returncode if proc.returncode is not None else -1,
+ stdout=stdout_result.data.decode("utf-8", "replace"),
+ stderr=stderr_result.data.decode("utf-8", "replace"),
+ duration_ms=int((time.perf_counter() - started) * 1000),
+ timed_out=timed_out,
+ truncated=stdout_result.truncated or stderr_result.truncated,
+ )
+
+
+__all__ = [
+ "MAX_ARGV_ELEMENT_CHARS",
+ "ProcessError",
+ "ProcessResult",
+ "base_env",
+ "run_process",
+]
diff --git a/apps/api/app/infrastructure/adapters/registry.py b/apps/api/app/infrastructure/adapters/registry.py
index 15470e6..34552d3 100644
--- a/apps/api/app/infrastructure/adapters/registry.py
+++ b/apps/api/app/infrastructure/adapters/registry.py
@@ -89,6 +89,13 @@ def get_sandbox_provider():
provider = settings.sandbox_provider.lower()
if provider == "mock":
return mock.LocalMockSandboxAdapter(max_output_bytes=settings.sandbox_max_output_bytes)
+ if provider == "docker":
+ # Local docker/podman. One instance per process so the workspace
+ # registry (external id → host dir) stays consistent between
+ # ``create_workspace`` and the ``start_session`` that follows it.
+ from app.infrastructure.adapters.docker_sandbox import DockerSandboxAdapter
+
+ return DockerSandboxAdapter()
if provider in {"daytona", "e2b"}:
from app.infrastructure.adapters.sandbox_remote import build_remote_sandbox_adapter
@@ -99,7 +106,8 @@ def get_sandbox_provider():
e2b_api_key=settings.e2b_api_key,
)
raise ProviderUnavailableError(
- f"SANDBOX_PROVIDER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | daytona | e2b"
+ f"SANDBOX_PROVIDER='{provider}' پشتیبانی نمیشود. "
+ "مقادیر مجاز: mock | docker | daytona | e2b"
)
diff --git a/apps/api/app/infrastructure/repositories/sandbox.py b/apps/api/app/infrastructure/repositories/sandbox.py
index a06eb31..cd42ec4 100644
--- a/apps/api/app/infrastructure/repositories/sandbox.py
+++ b/apps/api/app/infrastructure/repositories/sandbox.py
@@ -198,6 +198,29 @@ class SandboxSessionRepository:
)
return [_session(r) for r in (await self.session.execute(stmt)).scalars()]
+ async def list_active_external_ids(self, *, limit: int = 5_000) -> set[str]:
+ """External ids of every session that is not stopped.
+
+ The orphan reaper's query: a container whose id is not in this set has
+ no live row pointing at it, so it is holding resources for nobody.
+ """
+ stmt = (
+ select(SandboxSessionRow.external_session_id)
+ .where(
+ SandboxSessionRow.status.in_(
+ [
+ str(SessionStatus.STARTING),
+ str(SessionStatus.RUNNING),
+ str(SessionStatus.PAUSED),
+ ]
+ ),
+ SandboxSessionRow.external_session_id.is_not(None),
+ )
+ .limit(min(limit, 20_000))
+ )
+ rows = (await self.session.execute(stmt)).scalars().all()
+ return {str(value) for value in rows if value}
+
async def create(self, sandbox_session: SandboxSession) -> SandboxSession:
row = SandboxSessionRow(
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
diff --git a/apps/api/app/interfaces/http/routers/sandbox.py b/apps/api/app/interfaces/http/routers/sandbox.py
index 21cd8d6..d8cacc7 100644
--- a/apps/api/app/interfaces/http/routers/sandbox.py
+++ b/apps/api/app/interfaces/http/routers/sandbox.py
@@ -4,8 +4,17 @@ Note what is **absent**: there is no route that accepts a shell command and runs
it on the host. Every execution goes to ``SandboxProviderPort``, is bounded by a
policy timeout and output cap, and is metered into the usage ledger.
-Running a command requires ``sandbox.use``; tearing a workspace down requires
-``projects.manage`` because it destroys work.
+**Who may call what (v2).** Containers, ports and command output are
+*infrastructure*, and v2 gives infrastructure to the platform owner alone
+(PLAN-v2 §7.7): a customer only ever gives orders to the AI. So every mutating
+route here — create/start/stop a workspace or session, run a command, read or
+write a file, snapshot, mint a preview URL, set limits — requires the platform
+owner. The read-only status routes (templates, effective limits, workspace list,
+the command log) stay open to org members, because the project page shows them.
+
+The org-scoped prefix is kept even for owner-only routes: it is what scopes the
+audit trail and the tenancy check, and it means the platform owner must
+*deliberately* be a member of the org they are operating on.
"""
from __future__ import annotations
@@ -19,8 +28,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.sandbox_service import SandboxService
from app.core.database import get_session
+from app.core.errors import PermissionDeniedError
from app.domain.audit.models import AuditAction
-from app.domain.identity.models import CAN_MANAGE_PROJECTS, CAN_USE_SANDBOX
from app.domain.sandbox.models import NetworkPolicy
from app.interfaces.http.deps import (
OrgContext,
@@ -32,6 +41,19 @@ from app.interfaces.http.deps import (
router = APIRouter(prefix="/organizations/{org_id}/sandbox", tags=["sandbox"])
+def _require_platform_owner(context: OrgContext) -> None:
+ """Infrastructure belongs to the platform owner (PLAN-v2 §7.7).
+
+ Deliberately *not* an org-capability check: an org admin running a command
+ in a container is exactly the "users touch infrastructure" behaviour v2
+ removes.
+ """
+ if not context.user.is_platform_owner:
+ raise PermissionDeniedError(
+ "دسترسی به زیرساخت فقط برای مالک سامانه امکانپذیر است."
+ )
+
+
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
@@ -181,7 +203,7 @@ async def set_limits(
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> ResourceLimitOut:
- context.require(CAN_MANAGE_PROJECTS)
+ _require_platform_owner(context)
limit = await service.set_limits(
scope=payload.scope, scope_id=payload.scope_id or context.org_id,
cpu=payload.cpu, memory_mb=payload.memory_mb, disk_mb=payload.disk_mb,
@@ -211,7 +233,7 @@ async def create_workspace(
service: SandboxService = Depends(get_sandbox_service),
session: AsyncSession = Depends(get_session),
) -> WorkspaceOut:
- context.require(CAN_USE_SANDBOX)
+ _require_platform_owner(context)
workspace = await service.create_workspace(
organization_id=context.org_id,
@@ -264,7 +286,7 @@ async def destroy_workspace(
service: SandboxService = Depends(get_sandbox_service),
session: AsyncSession = Depends(get_session),
) -> WorkspaceOut:
- context.require(CAN_MANAGE_PROJECTS)
+ _require_platform_owner(context)
workspace = await service.destroy_workspace(
organization_id=context.org_id, workspace_id=workspace_id
)
@@ -295,7 +317,7 @@ async def start_session(
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> SessionOut:
- context.require(CAN_USE_SANDBOX)
+ _require_platform_owner(context)
sandbox_session = await service.start_session(
organization_id=context.org_id, workspace_id=payload.workspace_id
)
@@ -312,7 +334,7 @@ async def stop_session(
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> SessionOut:
- context.require(CAN_USE_SANDBOX)
+ _require_platform_owner(context)
sandbox_session = await service.stop_session(
organization_id=context.org_id, session_id=session_id
)
@@ -337,7 +359,7 @@ async def run_command(
service: SandboxService = Depends(get_sandbox_service),
session: AsyncSession = Depends(get_session),
) -> CommandRunOut:
- context.require(CAN_USE_SANDBOX)
+ _require_platform_owner(context)
run, _result = await service.run_command(
organization_id=context.org_id,
@@ -389,7 +411,7 @@ async def read_file(
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> FileReadOut:
- context.require(CAN_USE_SANDBOX)
+ _require_platform_owner(context)
content = await service.read_file(
organization_id=context.org_id, session_id=session_id, path=payload.path
)
@@ -403,7 +425,7 @@ async def write_file(
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> None:
- context.require(CAN_USE_SANDBOX)
+ _require_platform_owner(context)
await service.write_file(
organization_id=context.org_id, session_id=session_id,
path=payload.path, content=payload.content,
@@ -426,7 +448,7 @@ async def create_snapshot(
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> SnapshotOut:
- context.require(CAN_USE_SANDBOX)
+ _require_platform_owner(context)
snapshot = await service.snapshot(
organization_id=context.org_id, session_id=session_id, name=payload.name
)
@@ -442,7 +464,7 @@ async def get_preview_url(
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> PreviewUrlOut:
- context.require(CAN_USE_SANDBOX)
+ _require_platform_owner(context)
preview = await service.get_preview_url(
organization_id=context.org_id, session_id=session_id, port=port
)
diff --git a/apps/api/app/workers/__main__.py b/apps/api/app/workers/__main__.py
index 865782b..8a00807 100644
--- a/apps/api/app/workers/__main__.py
+++ b/apps/api/app/workers/__main__.py
@@ -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)
)
diff --git a/apps/api/app/workers/sandbox_gateway.py b/apps/api/app/workers/sandbox_gateway.py
index 06a6b19..c07fc62 100644
--- a/apps/api/app/workers/sandbox_gateway.py
+++ b/apps/api/app/workers/sandbox_gateway.py
@@ -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"]
diff --git a/apps/api/tests/test_docker_sandbox.py b/apps/api/tests/test_docker_sandbox.py
new file mode 100644
index 0000000..a1bc262
--- /dev/null
+++ b/apps/api/tests/test_docker_sandbox.py
@@ -0,0 +1,588 @@
+"""§v2-A3.1 — the docker sandbox adapter is the hardened execution boundary.
+
+The tests here are almost entirely about *arguments*: which flags are passed,
+which paths are mounted, which strings appear in ``argv``. That is deliberate.
+The adapter's job is not to be clever, it is to be exactly right about a small
+number of security-critical decisions, and the only way to hold that line
+against future edits is to assert on the literal argument vector.
+
+A fake runner stands in for the container runtime, so the suite needs no docker
+installed. The integration tests that *do* need it are marked ``docker`` and
+skipped unless ``SANDBOX_DOCKER_IT=1``.
+"""
+
+from __future__ import annotations
+
+import os
+
+import pytest
+import pytest_asyncio
+from httpx import AsyncClient
+
+from app.core.errors import PermissionDeniedError, ValidationError
+from app.infrastructure.adapters.docker_sandbox import (
+ LABEL_ORG,
+ LABEL_PROJECT,
+ LABEL_ROLE,
+ DockerSandboxAdapter,
+ PathEscapeError,
+ container_name,
+ image_for_language,
+ safe_join,
+)
+from app.infrastructure.adapters.process import ProcessResult, run_process
+from tests.conftest import auth_header
+
+# ---------------------------------------------------------------------------
+# A fake container runtime
+# ---------------------------------------------------------------------------
+
+
+class FakeRunner:
+ """Records every argv and replays scripted results in order.
+
+ Responses are command-aware by default, because the adapter's flow depends
+ on the subcommand: ``ps`` decides whether a container already exists,
+ ``run`` reports the new container id, everything else is inert. A test that
+ needs something else queues an explicit response in ``responses``.
+ """
+
+ def __init__(
+ self,
+ *,
+ container_id: str = "cid123",
+ stdout: str = "",
+ ps_stdout: str = "",
+ exit_code: int = 0,
+ ) -> None:
+ self.calls: list[tuple[tuple[str, ...], dict]] = []
+ self.responses: list[ProcessResult] = []
+ self.container_id = container_id
+ self.stdout = stdout
+ self.ps_stdout = ps_stdout
+ self.exit_code = exit_code
+
+ async def __call__(self, argv, **kwargs) -> ProcessResult: # noqa: ANN001, ANN003
+ self.calls.append((tuple(argv), kwargs))
+ if self.responses:
+ return self.responses.pop(0)
+ subcommand = argv[1] if len(argv) > 1 else ""
+ if subcommand == "ps":
+ out = self.ps_stdout
+ elif subcommand == "run":
+ out = f"{self.container_id}\n"
+ else:
+ out = self.stdout
+ return ProcessResult(argv=tuple(argv), exit_code=self.exit_code, stdout=out)
+
+ @property
+ def last_argv(self) -> tuple[str, ...]:
+ return self.calls[-1][0]
+
+
+def _adapter(tmp_path, runner: FakeRunner, **overrides) -> DockerSandboxAdapter:
+ kwargs = {
+ "provider_bin": "docker",
+ "workspaces_root": str(tmp_path),
+ "default_cpus": 2.0,
+ "default_memory_mb": 512,
+ }
+ kwargs.update(overrides)
+ return DockerSandboxAdapter(runner=runner, **kwargs)
+
+
+# ---------------------------------------------------------------------------
+# Image mapping
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ ("language", "expected"),
+ [
+ ("node", "node:20-bookworm"),
+ ("python", "python:3.13-bookworm"),
+ ("php", "php:8.3-cli"),
+ ("go", "golang:1.23"),
+ ("static", "debian:bookworm"),
+ ],
+)
+def test_image_for_language_maps_the_known_languages(language: str, expected: str):
+ assert image_for_language(language) == expected
+
+
+def test_image_for_an_unknown_language_falls_back():
+ assert image_for_language("cobol") == "debian:bookworm"
+ assert image_for_language(None) == "debian:bookworm"
+
+
+def test_container_name_is_deterministic_and_dns_safe():
+ name = container_name("workspace", "org123", "proj456")
+ assert name == "smartorg-workspace-org123-proj456"
+ assert container_name("workspace", "org123", "proj456") == name
+
+
+# ---------------------------------------------------------------------------
+# Path safety
+# ---------------------------------------------------------------------------
+
+
+def test_safe_join_accepts_paths_inside_the_root(tmp_path):
+ root = tmp_path / "work"
+ root.mkdir()
+ assert safe_join(str(root), "src/main.py") == str(root / "src/main.py")
+ assert safe_join(str(root), "/src/main.py") == str(root / "src/main.py")
+
+
+def test_safe_join_refuses_a_parent_escape(tmp_path):
+ root = tmp_path / "work"
+ root.mkdir()
+ with pytest.raises(PathEscapeError):
+ safe_join(str(root), "../secrets.env")
+ with pytest.raises(PathEscapeError):
+ safe_join(str(root), "a/b/../../../etc/passwd")
+
+
+def test_safe_join_refuses_a_symlink_that_escapes(tmp_path):
+ """A symlink planted inside the work dir must not become a host read."""
+ root = tmp_path / "work"
+ root.mkdir()
+ outside = tmp_path / "outside.txt"
+ outside.write_text("secret")
+ os.symlink(outside, root / "escape.txt")
+
+ with pytest.raises(PathEscapeError):
+ safe_join(str(root), "escape.txt")
+
+
+# ---------------------------------------------------------------------------
+# create_workspace
+# ---------------------------------------------------------------------------
+
+
+async def test_create_workspace_makes_a_private_host_dir(tmp_path):
+ runner = FakeRunner()
+ adapter = _adapter(tmp_path, runner)
+
+ info = await adapter.create_workspace(
+ organization_id="org1", project_id="proj1", template_id="t1", image="python:3.13-bookworm"
+ )
+
+ host_dir = tmp_path / "org1" / "proj1" / "work"
+ assert host_dir.is_dir(), "the bind-mount source must exist before `docker run`"
+ assert info.external_id == "ws-org1-proj1"
+ assert runner.calls == [], "creating a workspace must not shell out"
+
+
+async def test_create_workspace_refuses_a_hostile_id(tmp_path):
+ adapter = _adapter(tmp_path, FakeRunner())
+ with pytest.raises(ValidationError):
+ await adapter.create_workspace(
+ organization_id="../../etc", project_id="p", template_id="t", image="i"
+ )
+
+
+# ---------------------------------------------------------------------------
+# start_session — the hardening flags are the whole point
+# ---------------------------------------------------------------------------
+
+
+async def test_start_session_passes_every_hardening_flag(tmp_path):
+ runner = FakeRunner(container_id="deadbeef")
+ adapter = _adapter(tmp_path, runner)
+
+ info = await adapter.create_workspace(
+ organization_id="org1", project_id="proj1", template_id="t1", image="node:20-bookworm"
+ )
+ session = await adapter.start_session(workspace_external_id=info.external_id)
+
+ assert session.external_id == "deadbeef"
+ argv = runner.last_argv
+ assert argv[0] == "docker"
+ assert argv[1] == "run"
+
+ # Isolation
+ assert "--cap-drop" in argv and "ALL" in argv
+ assert "--security-opt" in argv and "no-new-privileges" in argv
+ assert "--user" in argv and "1000:1000" in argv
+ assert "--pids-limit" in argv and "512" in argv
+ # Resource limits come from the adapter's configuration
+ assert "--cpus" in argv and "2.0" in argv
+ assert "--memory" in argv and "512m" in argv
+ # Immutable root with scratch space
+ assert "--read-only" in argv
+ assert argv.count("--tmpfs") == 2
+ assert "/tmp" in argv and "/home/app" in argv
+ # Labels
+ assert f"{LABEL_ORG}=org1" in argv
+ assert f"{LABEL_PROJECT}=proj1" in argv
+ assert f"{LABEL_ROLE}=workspace" in argv
+
+
+async def test_start_session_mounts_only_the_work_tree(tmp_path):
+ """The git dir must never be visible inside the container (D6)."""
+ runner = FakeRunner(container_id="cid")
+ adapter = _adapter(tmp_path, runner)
+ info = await adapter.create_workspace(
+ organization_id="org1", project_id="proj1", template_id="t1", image="node:20-bookworm"
+ )
+ await adapter.start_session(workspace_external_id=info.external_id)
+
+ argv = runner.last_argv
+ mounts = [argv[i + 1] for i, token in enumerate(argv) if token == "-v"]
+ assert mounts == [f"{tmp_path}/org1/proj1/work:/workspace"], (
+ "exactly one bind mount, and it is the work tree"
+ )
+ assert not any("git" in mount for mount in mounts)
+
+
+async def test_start_session_publishes_no_port(tmp_path):
+ """A workspace container is reached with `exec`, never over the network."""
+ runner = FakeRunner(container_id="cid")
+ adapter = _adapter(tmp_path, runner)
+ info = await adapter.create_workspace(
+ organization_id="org1", project_id="proj1", template_id="t1", image="node:20-bookworm"
+ )
+ await adapter.start_session(workspace_external_id=info.external_id)
+
+ argv = runner.last_argv
+ assert "-p" not in argv
+ assert "--publish" not in argv
+ assert not any(token.startswith("0.0.0.0") for token in argv)
+
+
+async def test_start_session_reuses_an_existing_container(tmp_path):
+ """A restart must not stack duplicate containers for one project."""
+ runner = FakeRunner()
+ runner.responses = [
+ ProcessResult(argv=(), exit_code=0, stdout="existingcid\n"), # ps -a lookup
+ ProcessResult(argv=(), exit_code=0, stdout="existingcid\n"), # docker start
+ ]
+ adapter = _adapter(tmp_path, runner)
+ info = await adapter.create_workspace(
+ organization_id="org1", project_id="proj1", template_id="t1", image="node:20-bookworm"
+ )
+ session = await adapter.start_session(workspace_external_id=info.external_id)
+
+ assert session.external_id == "existingcid"
+ assert runner.calls[1][0][1] == "start"
+ assert all(call[0][1] != "run" for call in runner.calls), "must not run a second container"
+
+
+# ---------------------------------------------------------------------------
+# run_command
+# ---------------------------------------------------------------------------
+
+
+async def test_run_command_execs_inside_the_container_with_a_workdir(tmp_path):
+ runner = FakeRunner(stdout="ok\n")
+ adapter = _adapter(tmp_path, runner)
+
+ result = await adapter.run_command(
+ session_external_id="cid",
+ command="pytest -q",
+ timeout_seconds=120,
+ max_output_bytes=10_000,
+ )
+
+ assert result.stdout == "ok\n"
+ argv = runner.last_argv
+ assert argv[:3] == ("docker", "exec", "-w")
+ assert argv[3] == "/workspace"
+ assert argv[-3:] == ("sh", "-lc", "pytest -q")
+ assert runner.calls[-1][1]["timeout_seconds"] == 120.0
+ assert runner.calls[-1][1]["max_output_bytes"] == 10_000
+
+
+async def test_run_command_refuses_a_secret_in_the_environment(tmp_path):
+ """argv is world-readable via `ps`; credentials must never travel there."""
+ adapter = _adapter(tmp_path, FakeRunner())
+ with pytest.raises(ValidationError):
+ await adapter.run_command(
+ session_external_id="cid",
+ command="env",
+ timeout_seconds=30,
+ max_output_bytes=1_000,
+ env={"GIT_TOKEN": "ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
+ )
+
+
+async def test_run_command_allows_a_harmless_environment_variable(tmp_path):
+ runner = FakeRunner()
+ adapter = _adapter(tmp_path, runner)
+ await adapter.run_command(
+ session_external_id="cid",
+ command="node -v",
+ timeout_seconds=30,
+ max_output_bytes=1_000,
+ env={"NODE_ENV": "test"},
+ )
+ argv = runner.last_argv
+ assert "-e" in argv and "NODE_ENV=test" in argv
+
+
+async def test_run_command_refuses_an_empty_command(tmp_path):
+ adapter = _adapter(tmp_path, FakeRunner())
+ with pytest.raises(ValidationError):
+ await adapter.run_command(
+ session_external_id="cid", command=" ", timeout_seconds=10, max_output_bytes=100
+ )
+
+
+# ---------------------------------------------------------------------------
+# files
+# ---------------------------------------------------------------------------
+
+
+async def test_write_and_read_round_trip_through_the_bind_mount(tmp_path):
+ runner = FakeRunner(container_id="cid")
+ adapter = _adapter(tmp_path, runner)
+ info = await adapter.create_workspace(
+ organization_id="org1", project_id="proj1", template_id="t1", image="node:20-bookworm"
+ )
+ await adapter.start_session(workspace_external_id=info.external_id)
+
+ await adapter.write_file(session_external_id="cid", path="/workspace/src/a.ts", content="x")
+ assert await adapter.read_file(session_external_id="cid", path="/workspace/src/a.ts") == "x"
+ assert (tmp_path / "org1" / "proj1" / "work" / "src" / "a.ts").read_text() == "x"
+
+
+async def test_read_file_refuses_an_escape(tmp_path):
+ runner = FakeRunner(container_id="cid")
+ adapter = _adapter(tmp_path, runner)
+ info = await adapter.create_workspace(
+ organization_id="org1", project_id="proj1", template_id="t1", image="node:20-bookworm"
+ )
+ await adapter.start_session(workspace_external_id=info.external_id)
+
+ with pytest.raises(PathEscapeError):
+ await adapter.read_file(session_external_id="cid", path="../../../../etc/passwd")
+
+
+# ---------------------------------------------------------------------------
+# lifecycle
+# ---------------------------------------------------------------------------
+
+
+async def test_destroy_removes_the_container_and_its_volumes(tmp_path):
+ runner = FakeRunner(container_id="cid")
+ adapter = _adapter(tmp_path, runner)
+ await adapter.destroy(session_external_id="cid")
+
+ argv = runner.last_argv
+ assert argv[:3] == ("docker", "rm", "-f")
+ assert "-v" in argv
+ assert argv[-1] == "cid"
+
+
+async def test_pause_and_resume_map_to_stop_and_start(tmp_path):
+ runner = FakeRunner()
+ adapter = _adapter(tmp_path, runner)
+ await adapter.pause(session_external_id="cid")
+ assert runner.last_argv[1] == "stop"
+ await adapter.resume(session_external_id="cid")
+ assert runner.last_argv[1] == "start"
+
+
+async def test_preview_url_is_loopback_only(tmp_path):
+ adapter = _adapter(tmp_path, FakeRunner())
+ assert await adapter.get_preview_url(session_external_id="cid", port=8123) == (
+ "http://127.0.0.1:8123"
+ )
+
+
+async def test_preview_url_refuses_a_protected_platform_port(tmp_path):
+ from app.domain.platform.rules import RuleViolation
+
+ adapter = _adapter(tmp_path, FakeRunner())
+ with pytest.raises(RuleViolation):
+ await adapter.get_preview_url(session_external_id="cid", port=6379)
+
+
+# ---------------------------------------------------------------------------
+# orphan reaping
+# ---------------------------------------------------------------------------
+
+
+async def test_list_managed_containers_parses_labels(tmp_path):
+ runner = FakeRunner(
+ ps_stdout=(
+ "abc123\tctr-a\tUp 2 hours\t"
+ "smart-org.org=org1,smart-org.project=proj1,smart-org.role=workspace\n"
+ "def456\tctr-b\tExited (0)\t"
+ "smart-org.org=org2,smart-org.project=proj2,smart-org.role=demo\n"
+ )
+ )
+ adapter = _adapter(tmp_path, runner)
+ containers = await adapter.list_managed_containers()
+
+ assert [c.container_id for c in containers] == ["abc123", "def456"]
+ assert containers[0].organization_id == "org1"
+ assert containers[1].role == "demo"
+ assert runner.last_argv[1] == "ps"
+ assert f"label={LABEL_ROLE}" in runner.last_argv
+
+
+async def test_list_managed_containers_is_empty_when_the_cli_fails(tmp_path):
+ runner = FakeRunner(exit_code=1)
+ adapter = _adapter(tmp_path, runner)
+ assert await adapter.list_managed_containers() == []
+
+
+# ---------------------------------------------------------------------------
+# the shared process runner (real subprocesses, no container needed)
+# ---------------------------------------------------------------------------
+
+
+async def test_run_process_caps_output_and_reports_truncation():
+ result = await run_process(
+ ["sh", "-c", "yes abcdefgh | head -c 200000"],
+ timeout_seconds=30,
+ max_output_bytes=1_000,
+ )
+ assert result.exit_code == 0
+ assert result.truncated is True
+ assert len(result.stdout.encode()) <= 1_000
+
+
+async def test_run_process_kills_the_process_group_on_timeout():
+ result = await run_process(["sh", "-c", "sleep 30"], timeout_seconds=1)
+ assert result.timed_out is True
+ assert result.ok is False
+
+
+async def test_run_process_reports_a_missing_binary_instead_of_raising():
+ result = await run_process(["definitely-not-a-real-binary-xyz"], timeout_seconds=5)
+ assert result.spawn_failed is True
+ assert result.exit_code == 127
+ assert result.spawn_error == "FileNotFoundError"
+
+
+async def test_run_process_does_not_inherit_the_parent_environment(monkeypatch):
+ """A child must not see the platform's secrets (PLAN-v2 §7.5)."""
+ monkeypatch.setenv("JWT_SECRET", "super-secret-value-not-for-children")
+ monkeypatch.setenv("DATABASE_URL", "postgres://user:pw@host/db")
+
+ result = await run_process(
+ ["sh", "-c", "env"], timeout_seconds=10, max_output_bytes=50_000
+ )
+ assert "super-secret-value-not-for-children" not in result.stdout
+ assert "postgres://user:pw@host/db" not in result.stdout
+
+
+async def test_run_process_streams_output_to_the_callback():
+ chunks: list[str] = []
+
+ async def collect(chunk: str) -> None:
+ chunks.append(chunk)
+
+ await run_process(
+ ["sh", "-c", "printf 'hello'; printf 'world'"],
+ timeout_seconds=10,
+ on_output=collect,
+ )
+ assert "".join(chunks) == "helloworld"
+
+
+# ---------------------------------------------------------------------------
+# HTTP surface: infrastructure is owner-only
+# ---------------------------------------------------------------------------
+
+
+@pytest_asyncio.fixture(autouse=True)
+async def _platform_seed(seeded):
+ return seeded
+
+
+async def _owner_org(client: AsyncClient) -> tuple[str, dict]:
+ from app.domain.identity.models import PlatformRole
+ from tests.conftest import signup
+
+ token = (
+ await signup(client, "sandbox-owner@example.com", platform_role=PlatformRole.OWNER)
+ )["access_token"]
+ headers = auth_header(token)
+ org_id = (
+ await client.post("/api/organizations", json={"name": "سازمان زیرساخت"}, headers=headers)
+ ).json()["id"]
+ return org_id, headers
+
+
+async def _manager_org(client: AsyncClient) -> tuple[str, dict]:
+ from tests.conftest import signup
+
+ token = (await signup(client, "sandbox-manager@example.com"))["access_token"]
+ headers = auth_header(token)
+ org_id = (
+ await client.post("/api/organizations", json={"name": "سازمان مدیر"}, headers=headers)
+ ).json()["id"]
+ return org_id, headers
+
+
+async def test_org_member_cannot_run_a_sandbox_command(client: AsyncClient):
+ """The org's own owner is still not allowed to touch infrastructure."""
+ org_id, headers = await _manager_org(client)
+ template_id = (
+ await client.get(f"/api/organizations/{org_id}/sandbox/templates", headers=headers)
+ ).json()[0]["id"]
+
+ response = await client.post(
+ f"/api/organizations/{org_id}/sandbox/workspaces",
+ json={"project_id": "whatever", "template_id": template_id},
+ headers=headers,
+ )
+ assert response.status_code == 403, response.text
+
+
+async def test_org_member_may_still_read_sandbox_status(client: AsyncClient):
+ """Templates and limits are status, not infrastructure."""
+ org_id, headers = await _manager_org(client)
+ templates = await client.get(
+ f"/api/organizations/{org_id}/sandbox/templates", headers=headers
+ )
+ assert templates.status_code == 200
+ assert templates.json()
+
+ limits = await client.get(
+ f"/api/organizations/{org_id}/sandbox/limits", headers=headers
+ )
+ assert limits.status_code == 200
+ assert limits.json()["timeout_seconds"] >= 1
+
+
+async def test_owner_may_create_a_workspace(client: AsyncClient):
+ org_id, headers = await _owner_org(client)
+ project_id = (
+ await client.post(
+ f"/api/organizations/{org_id}/projects",
+ json={"name": "پروژهٔ زیرساخت"},
+ headers=headers,
+ )
+ ).json()["id"]
+ template_id = (
+ await client.get(f"/api/organizations/{org_id}/sandbox/templates", headers=headers)
+ ).json()[0]["id"]
+
+ response = await client.post(
+ f"/api/organizations/{org_id}/sandbox/workspaces",
+ json={"project_id": project_id, "template_id": template_id},
+ headers=headers,
+ )
+ assert response.status_code in (200, 201), response.text
+
+
+async def test_the_owner_check_helper_rejects_a_manager():
+ from app.domain.identity.models import Membership, PlatformRole, Role, User
+ from app.interfaces.http.routers.sandbox import _require_platform_owner
+
+ class _Ctx:
+ def __init__(self, user):
+ self.user = user
+
+ manager = User(email="m@x.com", password_hash="x", platform_role=PlatformRole.MANAGER)
+ with pytest.raises(PermissionDeniedError):
+ _require_platform_owner(_Ctx(manager))
+
+ owner = User(email="o@x.com", password_hash="x", platform_role=PlatformRole.OWNER)
+ _require_platform_owner(_Ctx(owner)) # must not raise
+
+ _ = (Membership, Role)
diff --git a/apps/api/tests/test_e2e_smoke.py b/apps/api/tests/test_e2e_smoke.py
index 295cb71..5cac516 100644
--- a/apps/api/tests/test_e2e_smoke.py
+++ b/apps/api/tests/test_e2e_smoke.py
@@ -23,6 +23,7 @@ from __future__ import annotations
import pytest_asyncio
from httpx import AsyncClient
+from app.domain.identity.models import PlatformRole
from tests.conftest import auth_header, signup
@@ -34,7 +35,14 @@ async def _platform_seed(seeded):
async def test_end_to_end_smoke_path(client: AsyncClient):
# -- 1. signup -------------------------------------------------------- #
- signup_body = await signup(client, "smoke@example.com", name="کاربر دود")
+ # The platform owner, not a plain manager: step 6 drives the sandbox
+ # directly, and v2 makes container/command access owner-only
+ # (PLAN-v2 §7.7). The org-scoped prefix still scopes the tenant and the
+ # audit trail, so this is the same path a customer walks minus the
+ # infrastructure step.
+ signup_body = await signup(
+ client, "smoke@example.com", name="کاربر دود", platform_role=PlatformRole.OWNER
+ )
token = signup_body["access_token"]
headers = auth_header(token)
diff --git a/apps/api/tests/test_workers.py b/apps/api/tests/test_workers.py
index f7da564..1bf3dc4 100644
--- a/apps/api/tests/test_workers.py
+++ b/apps/api/tests/test_workers.py
@@ -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)
diff --git a/apps/web/src/app/(app)/projects/[id]/page.tsx b/apps/web/src/app/(app)/projects/[id]/page.tsx
index 6c373e0..099ea95 100644
--- a/apps/web/src/app/(app)/projects/[id]/page.tsx
+++ b/apps/web/src/app/(app)/projects/[id]/page.tsx
@@ -34,7 +34,7 @@ export default function ProjectDetailPage({
params: Promise<{ id: string }>;
}) {
const { id: projectId } = React.use(params);
- const { activeOrganization } = useSession();
+ const { activeOrganization, user } = useSession();
const [tab, setTab] = React.useState("repo");
const [project, setProject] = React.useState(null);
@@ -136,7 +136,11 @@ export default function ProjectDetailPage({
) : tab === "preview" ? (
) : (
-
+
)}
);
diff --git a/apps/web/src/components/sandbox-panel.tsx b/apps/web/src/components/sandbox-panel.tsx
index 5158d16..892f7b4 100644
--- a/apps/web/src/components/sandbox-panel.tsx
+++ b/apps/web/src/components/sandbox-panel.tsx
@@ -10,6 +10,10 @@
* tampered client cannot widen them.
* 2. It shows no shell/exec affordance for the platform host. The only execution
* surface is a workspace session, which is isolated and metered.
+ * 3. Containers, ports and command output are *infrastructure*. In v2 only the
+ * platform owner may touch them (PLAN-v2 §7.7), so a non-owner sees status
+ * only: no create form, no session, no action buttons. The read-only routes
+ * stay open to org members, which is what keeps this panel useful to them.
*/
import * as React from "react";
@@ -58,7 +62,16 @@ function humanBytes(bytes: number): string {
return `${bytes} بایت`;
}
-export function SandboxPanel({ orgId, projectId }: { orgId: string; projectId: string }) {
+export function SandboxPanel({
+ orgId,
+ projectId,
+ canManageInfra = false,
+}: {
+ orgId: string;
+ projectId: string;
+ /** True only for the platform owner. Gates every mutating control. */
+ canManageInfra?: boolean;
+}) {
const [templates, setTemplates] = React.useState([]);
const [workspaces, setWorkspaces] = React.useState([]);
const [limits, setLimits] = React.useState(null);
@@ -177,41 +190,51 @@ export function SandboxPanel({ orgId, projectId }: { orgId: string; projectId: s
)}
-
- ساخت محیط ایزوله
-
- کد در یک محیط جداگانه اجرا میشود؛ هیچ دستوری روی سرور میزبان اجرا نمیگردد. هر
- دستور در دفتر مصرف ثبت میشود.
-
- {templates.length === 0 ? (
- قالبی در دسترس نیست.
- ) : (
-
- )}
- {templates.length > 0 && (
-
- {templates.find((t) => t.id === templateId)?.description ?? ""}
-
- )}
-
+ {canManageInfra ? (
+
+ ساخت محیط ایزوله
+
+ کد در یک محیط جداگانه اجرا میشود؛ هیچ دستوری روی سرور میزبان اجرا نمیگردد. هر
+ دستور در دفتر مصرف ثبت میشود.
+
+ {templates.length === 0 ? (
+ قالبی در دسترس نیست.
+ ) : (
+
+ )}
+ {templates.length > 0 && (
+
+ {templates.find((t) => t.id === templateId)?.description ?? ""}
+
+ )}
+
+ ) : (
+
+ محیط اجرا
+
+ زیرساخت (کانتینر، پورت و اجرای دستور) تنها توسط مالک سامانه مدیریت میشود. برای
+ اجرای کارها کافی است به مدیر هوش مصنوعی دستور بدهید.
+
+
+ )}
محیطهای این پروژه
@@ -242,23 +265,25 @@ export function SandboxPanel({ orgId, projectId }: { orgId: string; projectId: s
ارائهدهنده: {workspace.provider}
-
- onStartSession(workspace)}
- loading={starting && !isActive}
- >
- {isActive ? "انتخابشده" : "انتخاب و شروع نشست"}
-
- setPendingDestroy(workspace)}
- >
- حذف
-
-
+ {canManageInfra && (
+
+ onStartSession(workspace)}
+ loading={starting && !isActive}
+ >
+ {isActive ? "انتخابشده" : "انتخاب و شروع نشست"}
+
+ setPendingDestroy(workspace)}
+ >
+ حذف
+
+
+ )}
);
})}
@@ -266,7 +291,7 @@ export function SandboxPanel({ orgId, projectId }: { orgId: string; projectId: s
)}
- {activeWorkspace && session && (
+ {canManageInfra && activeWorkspace && session && (
({
+ sandboxTemplates: vi.fn(),
+ sandboxWorkspaces: vi.fn(),
+ sandboxLimits: vi.fn(),
+ sandboxCommands: vi.fn(),
+}));
+
+vi.mock("@/lib/api", () => {
+ class FakeApiError extends Error {
+ constructor(
+ readonly status: number,
+ readonly code: string,
+ message: string,
+ ) {
+ super(message);
+ }
+ }
+ return { ApiError: FakeApiError, api: mocks };
+});
+
+vi.mock("@/lib/realtime", () => ({ useLiveChannel: () => {} }));
+
+import { SandboxPanel } from "@/components/sandbox-panel";
+
+const TEMPLATE = {
+ id: "tpl-1",
+ name: "پایتون",
+ provider: "docker",
+ image: "python:3.13-bookworm",
+ description: "قالب پایتون",
+ default_cpu: 1,
+ default_memory_mb: 1024,
+ default_disk_mb: 2048,
+};
+
+const LIMITS = {
+ cpu: 1,
+ memory_mb: 1024,
+ disk_mb: 2048,
+ timeout_seconds: 120,
+ max_output_bytes: 200000,
+ network_policy: "deny_all",
+};
+
+const WORKSPACE = {
+ id: "ws-1",
+ project_id: "proj-1",
+ template_id: "tpl-1",
+ provider: "docker",
+ status: "ready",
+ created_at: new Date().toISOString(),
+};
+
+describe("SandboxPanel — owner-only infrastructure", () => {
+ beforeEach(() => {
+ mocks.sandboxTemplates.mockResolvedValue([TEMPLATE]);
+ mocks.sandboxWorkspaces.mockResolvedValue([WORKSPACE]);
+ mocks.sandboxLimits.mockResolvedValue(LIMITS);
+ mocks.sandboxCommands.mockResolvedValue([]);
+ });
+
+ it("shows an org member the status and nothing else", async () => {
+ render( );
+
+ // Status is still visible: the effective limits and the workspace list.
+ await waitFor(() => expect(screen.getByText("محدودیتهای مؤثر")).toBeInTheDocument());
+ expect(screen.getByText("محیطهای این پروژه")).toBeInTheDocument();
+ expect(screen.getByText("ws-1")).toBeInTheDocument();
+
+ // ...and the mutating affordances are absent, not merely disabled.
+ expect(screen.queryByText("ساخت محیط ایزوله")).not.toBeInTheDocument();
+ expect(screen.queryByText("ساخت محیط")).not.toBeInTheDocument();
+ expect(screen.queryByText("انتخاب و شروع نشست")).not.toBeInTheDocument();
+ expect(screen.queryByText("حذف")).not.toBeInTheDocument();
+ expect(screen.queryByText("نشست فعال")).not.toBeInTheDocument();
+
+ // The replacement card explains where the capability went.
+ expect(screen.getByText("محیط اجرا")).toBeInTheDocument();
+ });
+
+ it("gives the platform owner the full control surface", async () => {
+ render( );
+
+ await waitFor(() => expect(screen.getByText("ساخت محیط ایزوله")).toBeInTheDocument());
+ expect(screen.getByText("ساخت محیط")).toBeInTheDocument();
+ expect(screen.getByText("انتخاب و شروع نشست")).toBeInTheDocument();
+ expect(screen.getByText("حذف")).toBeInTheDocument();
+ expect(screen.queryByText("محیط اجرا")).not.toBeInTheDocument();
+ });
+
+ it("defaults to the restricted view when the flag is omitted", async () => {
+ // A call site that forgets the prop must fail *closed*.
+ render( );
+ await waitFor(() => expect(screen.getByText("محیطهای این پروژه")).toBeInTheDocument());
+ expect(screen.queryByText("ساخت محیط")).not.toBeInTheDocument();
+ });
+});
diff --git a/docs/plan/reports/agent-3-phase-1.md b/docs/plan/reports/agent-3-phase-1.md
new file mode 100644
index 0000000..b0624e7
--- /dev/null
+++ b/docs/plan/reports/agent-3-phase-1.md
@@ -0,0 +1,142 @@
+# Agent 3 — Phase 1 report: Docker sandbox adapter
+
+Date: 2026-10-13 · Branch: `dev` (work done directly on `dev`; see "Deviations")
+
+## What was done
+
+### 1.1 `DockerSandboxAdapter` (`app/infrastructure/adapters/docker_sandbox.py`)
+
+Implements `SandboxProviderPort` over the docker/podman CLI, with the binary taken from
+`SANDBOX_DOCKER_BIN` so podman is a drop-in (the flags used are the common subset).
+
+- `create_workspace` — creates `SANDBOX_WORKSPACES_ROOT///work` at `0700`, and
+ encodes the workspace identity in the external id (`ws--`) so a restarted
+ process can rebuild the host paths without a database round-trip. One workspace per
+ application, per D5.
+- `start_session` — `docker run -d` with the full hardening set:
+ `--user 1000:1000`, `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit 512`,
+ `--cpus`, `--memory`, `--read-only` + `--tmpfs /tmp` + `--tmpfs /home/app`,
+ a single bind mount of the work dir at `/workspace`, labels
+ `smart-org.org` / `smart-org.project` / `smart-org.role`, default bridge network and **no
+ published ports**. It reuses an existing container of the same name rather than stacking
+ duplicates after a restart.
+- `run_command` — `docker exec -w sh -lc `. `cwd` and `on_output` are
+ adapter-level extras for the engine; the port signature is unchanged. Environment variables
+ are passed with `-e`, and any key that looks like a secret is **refused** — argv is visible
+ in `ps`, so credentials must never travel that way.
+- `read_file` / `write_file` — through the host bind mount, with `safe_join` path safety
+ (rejects `..`, absolute escapes and symlinks that resolve outside the work dir).
+- `pause` / `resume` / `destroy` — stop / start / `rm -f -v`.
+- `snapshot` — `docker commit`, documented as capturing the root filesystem only (the work
+ tree is a bind mount and is *not* included).
+- `get_preview_url` — `http://127.0.0.1:`, and it refuses a platform-protected port.
+- Extras used by later phases: `start_demo_container` (loopback-only publish, same hardening,
+ `role=demo`), `list_managed_containers`, `remove_container`, `host_dir_for`,
+ `binary_available`, plus the module-level `image_for_language` map
+ (node:20-bookworm, python:3.13-bookworm, php:8.3-cli, golang:1.23, debian:bookworm fallback).
+
+### 1.1a `app/infrastructure/adapters/process.py` (new, shared)
+
+The one place the platform shells out. `run_process` exists to make four failure modes
+impossible:
+
+- **shell injection** — `create_subprocess_exec` with an argv list, never `shell=True`;
+- **orphaned children** — `start_new_session=True` and `killpg` on timeout (SIGTERM then
+ SIGKILL), so a `docker build`'s helpers die with it;
+- **pipe deadlock** — stdout and stderr drained concurrently by two tasks, with a byte cap
+ applied while streaming (the callback still sees every chunk, so live logs stay complete);
+- **secret inheritance** — an explicit minimal `env` (a handful of safe keys), never
+ `os.environ` wholesale.
+
+A missing binary returns `spawn_failed=True` rather than raising; a non-zero exit or a
+timeout is an ordinary result the caller inspects.
+
+### 1.2 Registry
+
+`get_sandbox_provider()` now maps `SANDBOX_PROVIDER=docker` to the adapter (cached as a
+singleton so the workspace registry stays consistent between `create_workspace` and the
+`start_session` that follows). `mock` remains the default; `daytona`/`e2b` are unchanged.
+
+### 1.3 Orphan reaping
+
+`SandboxGatewayWorker.reap_orphans()` lists every container carrying our labels and removes
+any whose id is not in `SandboxSessionRepository.list_active_external_ids()` (new method).
+Each removal is audited as `sandbox.reap` with `reason=orphan_container`. A provider without
+`list_managed_containers` is skipped silently.
+`SandboxGatewayWorker.run_pass()` runs idle-session reaping and orphan reaping in one pass,
+and `workers/__main__.py` now calls it.
+
+### 1.4 Infrastructure hidden from non-owners
+
+`app/interfaces/http/routers/sandbox.py`: every **mutating** route now requires the platform
+owner via a local `_require_platform_owner(context)` helper — create/destroy workspace,
+start/stop session, run command, read/write file, snapshot, preview URL, set limits. The
+**read-only status** routes (templates, effective limits, workspace list, command log) stay
+open to org members, because the project page shows them.
+
+`components/sandbox-panel.tsx` takes a `canManageInfra` prop (default `false`, so a call site
+that forgets it fails closed). A non-owner sees the limits card, the workspace list and a
+short note that infrastructure is managed by the platform owner; the create form, the session
+panel and the per-workspace action buttons are not rendered at all (not merely disabled).
+`(app)/projects/[id]/page.tsx` passes `canManageInfra={user?.is_platform_owner ?? false}`.
+
+## Files changed
+
+| File | Change |
+|---|---|
+| `app/infrastructure/adapters/process.py` | new — hardened host subprocess runner |
+| `app/infrastructure/adapters/docker_sandbox.py` | new — the docker/podman sandbox adapter |
+| `app/infrastructure/adapters/registry.py` | `SANDBOX_PROVIDER=docker` branch |
+| `app/infrastructure/repositories/sandbox.py` | `list_active_external_ids()` |
+| `app/workers/sandbox_gateway.py` | `reap_orphans()`, `run_pass()` |
+| `app/workers/__main__.py` | sandbox-gateway pass calls `run_pass()` |
+| `app/interfaces/http/routers/sandbox.py` | owner-only mutating routes |
+| `tests/test_docker_sandbox.py` | new — 37 tests |
+| `tests/test_workers.py` | 2 sandbox tests now use a platform-owner org |
+| `tests/test_e2e_smoke.py` | smoke path signs up as the platform owner |
+| `apps/web/src/components/sandbox-panel.tsx` | `canManageInfra` gating |
+| `apps/web/src/app/(app)/projects/[id]/page.tsx` | passes the owner flag |
+| `apps/web/src/test/delivery-sandbox-panel.test.tsx` | new — 3 tests |
+| `.gitignore` | `.pytest-tmp/` |
+
+## Test output
+
+```
+apps/api: 346 passed (was 309; +37)
+ ruff check . -> All checks passed!
+ mypy app -> Success: no issues found in 127 source files
+apps/web: 67 passed (was 64; +3)
+ tsc --noEmit -> clean
+ next lint -> No ESLint warnings or errors
+```
+
+## Deviations from the TODO
+
+1. **No worktrees.** The plan's environment (exFAT `/projects`, ext4 worktrees under
+ `/home/mamad/work/`) describes a Linux box. This machine is macOS with the repo at
+ `/Users/mamad/Projects/agy-bot/web`; all work is done on `dev` and committed per phase.
+2. **Owner-only rather than "admin+ with a setting".** The TODO offered both; owner-only is
+ the stated preference and matches "users never touch infrastructure" (PLAN-v2 §1.9).
+3. **Test updates.** Making sandbox mutation owner-only breaks three existing tests that drove
+ those routes as a plain manager. `test_e2e_smoke.py` and the two sandbox-gateway tests in
+ `test_workers.py` now sign up as the platform owner. No assertion was weakened; only the
+ actor's platform role changed.
+4. **`tmp_path` needs a writable `TMPDIR` on this machine.** The pre-existing
+ `$TMPDIR/pytest-of-unknown` directory is rejected by the local sandbox broker, so pytest
+ cannot create its base temp dir. Tests are run with `TMPDIR=/.pytest-tmp/tmproot`
+ (now gitignored). This is an environment quirk, not a code change.
+
+## Open questions / CHANGE REQUESTS
+
+- **None.** `RepoWorkspacePort`, `DeliveryHooks` and `DeployTargetTester` are consumed exactly
+ as frozen; the docker adapter adds methods rather than changing the port.
+
+## Known limitations
+
+- The workspace registry (external id → image) is process-local. A cold process rebuilds the
+ host dir from the encoded external id but falls back to the default image; in practice
+ `create_workspace` and `start_session` happen in the same request.
+- `snapshot` does not capture the work tree (bind mount). Documented in the docstring.
+- No `@pytest.mark.docker` integration test yet; the fake runner covers the argv contract,
+ which is where the security properties live. A live round-trip is added in Phase 3, where
+ the build/demo containers make it meaningful.