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
+3
View File
@@ -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/
@@ -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-<org>-<project>``) 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",
]
@@ -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",
]
@@ -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"
)
@@ -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,
+35 -13
View File
@@ -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
)
+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"]
+588
View File
@@ -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)
+9 -1
View File
@@ -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)
+15 -5
View File
@@ -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)
@@ -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<Tab>("repo");
const [project, setProject] = React.useState<Project | null>(null);
@@ -136,7 +136,11 @@ export default function ProjectDetailPage({
) : tab === "preview" ? (
<PreviewDomainPanel orgId={activeOrganization.id} projectId={project.id} />
) : (
<SandboxPanel orgId={activeOrganization.id} projectId={project.id} />
<SandboxPanel
orgId={activeOrganization.id}
projectId={project.id}
canManageInfra={user?.is_platform_owner ?? false}
/>
)}
</div>
);
+79 -54
View File
@@ -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<SandboxTemplate[]>([]);
const [workspaces, setWorkspaces] = React.useState<SandboxWorkspace[]>([]);
const [limits, setLimits] = React.useState<SandboxResourceLimit | null>(null);
@@ -177,41 +190,51 @@ export function SandboxPanel({ orgId, projectId }: { orgId: string; projectId: s
</Card>
)}
<Card>
<CardTitle>ساخت محیط ایزوله</CardTitle>
<CardHint>
کد در یک محیط جداگانه اجرا می‌شود؛ هیچ دستوری روی سرور میزبان اجرا نمی‌گردد. هر
دستور در دفتر مصرف ثبت می‌شود.
</CardHint>
{templates.length === 0 ? (
<p className="mt-3 text-xs text-slate-500">قالبی در دسترس نیست.</p>
) : (
<form
onSubmit={onCreateWorkspace}
className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end"
>
<div className="flex-1">
<Select
label="قالب محیط"
value={templateId}
onChange={(e) => setTemplateId(e.target.value)}
options={templates.map((template) => ({
value: template.id,
label: `${template.name} — ${template.image}`,
}))}
/>
</div>
<Button type="submit" loading={creating}>
ساخت محیط
</Button>
</form>
)}
{templates.length > 0 && (
<p className="mt-2 text-xs text-slate-500">
{templates.find((t) => t.id === templateId)?.description ?? ""}
</p>
)}
</Card>
{canManageInfra ? (
<Card>
<CardTitle>ساخت محیط ایزوله</CardTitle>
<CardHint>
کد در یک محیط جداگانه اجرا می‌شود؛ هیچ دستوری روی سرور میزبان اجرا نمی‌گردد. هر
دستور در دفتر مصرف ثبت می‌شود.
</CardHint>
{templates.length === 0 ? (
<p className="mt-3 text-xs text-slate-500">قالبی در دسترس نیست.</p>
) : (
<form
onSubmit={onCreateWorkspace}
className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end"
>
<div className="flex-1">
<Select
label="قالب محیط"
value={templateId}
onChange={(e) => setTemplateId(e.target.value)}
options={templates.map((template) => ({
value: template.id,
label: `${template.name} — ${template.image}`,
}))}
/>
</div>
<Button type="submit" loading={creating}>
ساخت محیط
</Button>
</form>
)}
{templates.length > 0 && (
<p className="mt-2 text-xs text-slate-500">
{templates.find((t) => t.id === templateId)?.description ?? ""}
</p>
)}
</Card>
) : (
<Card>
<CardTitle>محیط اجرا</CardTitle>
<CardHint>
زیرساخت (کانتینر، پورت و اجرای دستور) تنها توسط مالک سامانه مدیریت می‌شود. برای
اجرای کارها کافی است به مدیر هوش مصنوعی دستور بدهید.
</CardHint>
</Card>
)}
<Card>
<CardTitle>محیط‌های این پروژه</CardTitle>
@@ -242,23 +265,25 @@ export function SandboxPanel({ orgId, projectId }: { orgId: string; projectId: s
ارائه‌دهنده: <span className="ltr-inline">{workspace.provider}</span>
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
size="sm"
variant={isActive ? "ghost" : "secondary"}
onClick={() => onStartSession(workspace)}
loading={starting && !isActive}
>
{isActive ? "انتخاب‌شده" : "انتخاب و شروع نشست"}
</Button>
<Button
size="sm"
variant="danger"
onClick={() => setPendingDestroy(workspace)}
>
حذف
</Button>
</div>
{canManageInfra && (
<div className="flex shrink-0 items-center gap-2">
<Button
size="sm"
variant={isActive ? "ghost" : "secondary"}
onClick={() => onStartSession(workspace)}
loading={starting && !isActive}
>
{isActive ? "انتخاب‌شده" : "انتخاب و شروع نشست"}
</Button>
<Button
size="sm"
variant="danger"
onClick={() => setPendingDestroy(workspace)}
>
حذف
</Button>
</div>
)}
</li>
);
})}
@@ -266,7 +291,7 @@ export function SandboxPanel({ orgId, projectId }: { orgId: string; projectId: s
)}
</Card>
{activeWorkspace && session && (
{canManageInfra && activeWorkspace && session && (
<SessionPanel
orgId={orgId}
session={session}
@@ -0,0 +1,114 @@
/**
* §v2-A3.1.4 — infrastructure is the platform owner's alone.
*
* The sandbox panel used to expose a command box, a file editor, a snapshot
* button and a preview-port picker to anyone in the organization. In v2 a
* customer gives orders to the AI and never touches a container, so the same
* component must render *status only* for a non-owner — and the backend now
* answers 403 to the routes the hidden controls used to call.
*
* The assertion that matters is a negative one: the mutating controls must not
* be in the document at all. A merely disabled button would still be a
* discoverable affordance and would still be reachable by a script.
*/
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
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(<SandboxPanel orgId="org-1" projectId="proj-1" canManageInfra={false} />);
// 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(<SandboxPanel orgId="org-1" projectId="proj-1" canManageInfra />);
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(<SandboxPanel orgId="org-1" projectId="proj-1" />);
await waitFor(() => expect(screen.getByText("محیط‌های این پروژه")).toBeInTheDocument());
expect(screen.queryByText("ساخت محیط")).not.toBeInTheDocument();
});
});
+142
View File
@@ -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/<org>/<project>/work` at `0700`, and
encodes the workspace identity in the external id (`ws-<org>-<project>`) 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 <cwd> <ctr> sh -lc <cmd>`. `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:<port>`, 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=<repo>/.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.