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:
@@ -57,3 +57,6 @@ htmlcov/
|
|||||||
|
|
||||||
# v2 runtime data (workspaces, artifacts)
|
# v2 runtime data (workspaces, artifacts)
|
||||||
.data/
|
.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()
|
provider = settings.sandbox_provider.lower()
|
||||||
if provider == "mock":
|
if provider == "mock":
|
||||||
return mock.LocalMockSandboxAdapter(max_output_bytes=settings.sandbox_max_output_bytes)
|
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"}:
|
if provider in {"daytona", "e2b"}:
|
||||||
from app.infrastructure.adapters.sandbox_remote import build_remote_sandbox_adapter
|
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,
|
e2b_api_key=settings.e2b_api_key,
|
||||||
)
|
)
|
||||||
raise ProviderUnavailableError(
|
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()]
|
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:
|
async def create(self, sandbox_session: SandboxSession) -> SandboxSession:
|
||||||
row = SandboxSessionRow(
|
row = SandboxSessionRow(
|
||||||
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
|
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
|
||||||
|
|||||||
@@ -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
|
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.
|
policy timeout and output cap, and is metered into the usage ledger.
|
||||||
|
|
||||||
Running a command requires ``sandbox.use``; tearing a workspace down requires
|
**Who may call what (v2).** Containers, ports and command output are
|
||||||
``projects.manage`` because it destroys work.
|
*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
|
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.audit_service import record_audit
|
||||||
from app.application.services.sandbox_service import SandboxService
|
from app.application.services.sandbox_service import SandboxService
|
||||||
from app.core.database import get_session
|
from app.core.database import get_session
|
||||||
|
from app.core.errors import PermissionDeniedError
|
||||||
from app.domain.audit.models import AuditAction
|
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.domain.sandbox.models import NetworkPolicy
|
||||||
from app.interfaces.http.deps import (
|
from app.interfaces.http.deps import (
|
||||||
OrgContext,
|
OrgContext,
|
||||||
@@ -32,6 +41,19 @@ from app.interfaces.http.deps import (
|
|||||||
router = APIRouter(prefix="/organizations/{org_id}/sandbox", tags=["sandbox"])
|
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
|
# Schemas
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -181,7 +203,7 @@ async def set_limits(
|
|||||||
context: OrgContext = Depends(get_org_context),
|
context: OrgContext = Depends(get_org_context),
|
||||||
service: SandboxService = Depends(get_sandbox_service),
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
) -> ResourceLimitOut:
|
) -> ResourceLimitOut:
|
||||||
context.require(CAN_MANAGE_PROJECTS)
|
_require_platform_owner(context)
|
||||||
limit = await service.set_limits(
|
limit = await service.set_limits(
|
||||||
scope=payload.scope, scope_id=payload.scope_id or context.org_id,
|
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,
|
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),
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> WorkspaceOut:
|
) -> WorkspaceOut:
|
||||||
context.require(CAN_USE_SANDBOX)
|
_require_platform_owner(context)
|
||||||
|
|
||||||
workspace = await service.create_workspace(
|
workspace = await service.create_workspace(
|
||||||
organization_id=context.org_id,
|
organization_id=context.org_id,
|
||||||
@@ -264,7 +286,7 @@ async def destroy_workspace(
|
|||||||
service: SandboxService = Depends(get_sandbox_service),
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> WorkspaceOut:
|
) -> WorkspaceOut:
|
||||||
context.require(CAN_MANAGE_PROJECTS)
|
_require_platform_owner(context)
|
||||||
workspace = await service.destroy_workspace(
|
workspace = await service.destroy_workspace(
|
||||||
organization_id=context.org_id, workspace_id=workspace_id
|
organization_id=context.org_id, workspace_id=workspace_id
|
||||||
)
|
)
|
||||||
@@ -295,7 +317,7 @@ async def start_session(
|
|||||||
context: OrgContext = Depends(get_org_context),
|
context: OrgContext = Depends(get_org_context),
|
||||||
service: SandboxService = Depends(get_sandbox_service),
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
) -> SessionOut:
|
) -> SessionOut:
|
||||||
context.require(CAN_USE_SANDBOX)
|
_require_platform_owner(context)
|
||||||
sandbox_session = await service.start_session(
|
sandbox_session = await service.start_session(
|
||||||
organization_id=context.org_id, workspace_id=payload.workspace_id
|
organization_id=context.org_id, workspace_id=payload.workspace_id
|
||||||
)
|
)
|
||||||
@@ -312,7 +334,7 @@ async def stop_session(
|
|||||||
context: OrgContext = Depends(get_org_context),
|
context: OrgContext = Depends(get_org_context),
|
||||||
service: SandboxService = Depends(get_sandbox_service),
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
) -> SessionOut:
|
) -> SessionOut:
|
||||||
context.require(CAN_USE_SANDBOX)
|
_require_platform_owner(context)
|
||||||
sandbox_session = await service.stop_session(
|
sandbox_session = await service.stop_session(
|
||||||
organization_id=context.org_id, session_id=session_id
|
organization_id=context.org_id, session_id=session_id
|
||||||
)
|
)
|
||||||
@@ -337,7 +359,7 @@ async def run_command(
|
|||||||
service: SandboxService = Depends(get_sandbox_service),
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> CommandRunOut:
|
) -> CommandRunOut:
|
||||||
context.require(CAN_USE_SANDBOX)
|
_require_platform_owner(context)
|
||||||
|
|
||||||
run, _result = await service.run_command(
|
run, _result = await service.run_command(
|
||||||
organization_id=context.org_id,
|
organization_id=context.org_id,
|
||||||
@@ -389,7 +411,7 @@ async def read_file(
|
|||||||
context: OrgContext = Depends(get_org_context),
|
context: OrgContext = Depends(get_org_context),
|
||||||
service: SandboxService = Depends(get_sandbox_service),
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
) -> FileReadOut:
|
) -> FileReadOut:
|
||||||
context.require(CAN_USE_SANDBOX)
|
_require_platform_owner(context)
|
||||||
content = await service.read_file(
|
content = await service.read_file(
|
||||||
organization_id=context.org_id, session_id=session_id, path=payload.path
|
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),
|
context: OrgContext = Depends(get_org_context),
|
||||||
service: SandboxService = Depends(get_sandbox_service),
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
) -> None:
|
) -> None:
|
||||||
context.require(CAN_USE_SANDBOX)
|
_require_platform_owner(context)
|
||||||
await service.write_file(
|
await service.write_file(
|
||||||
organization_id=context.org_id, session_id=session_id,
|
organization_id=context.org_id, session_id=session_id,
|
||||||
path=payload.path, content=payload.content,
|
path=payload.path, content=payload.content,
|
||||||
@@ -426,7 +448,7 @@ async def create_snapshot(
|
|||||||
context: OrgContext = Depends(get_org_context),
|
context: OrgContext = Depends(get_org_context),
|
||||||
service: SandboxService = Depends(get_sandbox_service),
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
) -> SnapshotOut:
|
) -> SnapshotOut:
|
||||||
context.require(CAN_USE_SANDBOX)
|
_require_platform_owner(context)
|
||||||
snapshot = await service.snapshot(
|
snapshot = await service.snapshot(
|
||||||
organization_id=context.org_id, session_id=session_id, name=payload.name
|
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),
|
context: OrgContext = Depends(get_org_context),
|
||||||
service: SandboxService = Depends(get_sandbox_service),
|
service: SandboxService = Depends(get_sandbox_service),
|
||||||
) -> PreviewUrlOut:
|
) -> PreviewUrlOut:
|
||||||
context.require(CAN_USE_SANDBOX)
|
_require_platform_owner(context)
|
||||||
preview = await service.get_preview_url(
|
preview = await service.get_preview_url(
|
||||||
organization_id=context.org_id, session_id=session_id, port=port
|
organization_id=context.org_id, session_id=session_id, port=port
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ def _sandbox_gateway_pass(args: argparse.Namespace):
|
|||||||
gateway = SandboxGatewayWorker()
|
gateway = SandboxGatewayWorker()
|
||||||
|
|
||||||
async def one_pass() -> WorkerOutcome:
|
async def one_pass() -> WorkerOutcome:
|
||||||
return await gateway.reap(
|
return await gateway.run_pass(
|
||||||
idle_minutes=args.idle_minutes, limit=max(args.limit, 200)
|
idle_minutes=args.idle_minutes, limit=max(args.limit, 200)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,19 @@ from datetime import timedelta
|
|||||||
from app.application.container import build_sandbox_service
|
from app.application.container import build_sandbox_service
|
||||||
from app.application.ports.realtime import emit_sandbox_reaped
|
from app.application.ports.realtime import emit_sandbox_reaped
|
||||||
from app.core.config import settings
|
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.domain.base import utcnow
|
||||||
|
from app.infrastructure.error_log import record_error
|
||||||
from app.infrastructure.repositories.audit import AuditRepository
|
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
|
from app.workers.base import WorkerOutcome, worker_session
|
||||||
|
|
||||||
#: Sessions are considered abandoned after this long with no activity.
|
#: Sessions are considered abandoned after this long with no activity.
|
||||||
@@ -38,6 +47,22 @@ class SandboxGatewayWorker:
|
|||||||
|
|
||||||
name = "sandbox-gateway"
|
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(
|
async def reap(
|
||||||
self, *, idle_minutes: int = DEFAULT_IDLE_MINUTES, limit: int = 200
|
self, *, idle_minutes: int = DEFAULT_IDLE_MINUTES, limit: int = 200
|
||||||
) -> WorkerOutcome:
|
) -> WorkerOutcome:
|
||||||
@@ -98,5 +123,86 @@ class SandboxGatewayWorker:
|
|||||||
outcome.note("egress policy default = deny_all (set per resource limit)")
|
outcome.note("egress policy default = deny_all (set per resource limit)")
|
||||||
return outcome
|
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"]
|
__all__ = ["DEFAULT_IDLE_MINUTES", "SandboxGatewayWorker"]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -23,6 +23,7 @@ from __future__ import annotations
|
|||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from app.domain.identity.models import PlatformRole
|
||||||
from tests.conftest import auth_header, signup
|
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):
|
async def test_end_to_end_smoke_path(client: AsyncClient):
|
||||||
# -- 1. signup -------------------------------------------------------- #
|
# -- 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"]
|
token = signup_body["access_token"]
|
||||||
headers = auth_header(token)
|
headers = auth_header(token)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import pytest_asyncio
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from app.domain.base import utcnow
|
from app.domain.base import utcnow
|
||||||
|
from app.domain.identity.models import PlatformRole
|
||||||
from app.workers import base as worker_base
|
from app.workers import base as worker_base
|
||||||
from app.workers.ai_runtime import AiRuntimeWorker
|
from app.workers.ai_runtime import AiRuntimeWorker
|
||||||
from app.workers.billing import BillingWorker
|
from app.workers.billing import BillingWorker
|
||||||
@@ -48,9 +49,18 @@ async def _workers_use_the_test_database(monkeypatch, session_factory):
|
|||||||
return session_factory
|
return session_factory
|
||||||
|
|
||||||
|
|
||||||
async def _make_org_with_employee(client: AsyncClient, email: str) -> tuple[str, dict, str, str]:
|
async def _make_org_with_employee(
|
||||||
"""Return ``(org_id, headers, project_id, employee_id)`` for a fresh tenant."""
|
client: AsyncClient,
|
||||||
token = (await signup(client, email))["access_token"]
|
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)
|
headers = auth_header(token)
|
||||||
org_id = (
|
org_id = (
|
||||||
await client.post("/api/organizations", json={"name": "سازمان کارگر"}, headers=headers)
|
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):
|
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."""
|
"""An abandoned session must be stopped, and the stop must be auditable."""
|
||||||
org_id, headers, project_id, _ = await _make_org_with_employee(
|
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 = (
|
template_id = (
|
||||||
await client.get(f"/api/organizations/{org_id}/sandbox/templates", headers=headers)
|
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):
|
async def test_sandbox_gateway_leaves_a_fresh_session_alone(client: AsyncClient):
|
||||||
"""A session that was just used must not be reaped."""
|
"""A session that was just used must not be reaped."""
|
||||||
org_id, headers, project_id, _ = await _make_org_with_employee(
|
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 = (
|
template_id = (
|
||||||
await client.get(f"/api/organizations/{org_id}/sandbox/templates", headers=headers)
|
await client.get(f"/api/organizations/{org_id}/sandbox/templates", headers=headers)
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function ProjectDetailPage({
|
|||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { id: projectId } = React.use(params);
|
const { id: projectId } = React.use(params);
|
||||||
const { activeOrganization } = useSession();
|
const { activeOrganization, user } = useSession();
|
||||||
|
|
||||||
const [tab, setTab] = React.useState<Tab>("repo");
|
const [tab, setTab] = React.useState<Tab>("repo");
|
||||||
const [project, setProject] = React.useState<Project | null>(null);
|
const [project, setProject] = React.useState<Project | null>(null);
|
||||||
@@ -136,7 +136,11 @@ export default function ProjectDetailPage({
|
|||||||
) : tab === "preview" ? (
|
) : tab === "preview" ? (
|
||||||
<PreviewDomainPanel orgId={activeOrganization.id} projectId={project.id} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
* tampered client cannot widen them.
|
* tampered client cannot widen them.
|
||||||
* 2. It shows no shell/exec affordance for the platform host. The only execution
|
* 2. It shows no shell/exec affordance for the platform host. The only execution
|
||||||
* surface is a workspace session, which is isolated and metered.
|
* 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";
|
import * as React from "react";
|
||||||
@@ -58,7 +62,16 @@ function humanBytes(bytes: number): string {
|
|||||||
return `${bytes} بایت`;
|
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 [templates, setTemplates] = React.useState<SandboxTemplate[]>([]);
|
||||||
const [workspaces, setWorkspaces] = React.useState<SandboxWorkspace[]>([]);
|
const [workspaces, setWorkspaces] = React.useState<SandboxWorkspace[]>([]);
|
||||||
const [limits, setLimits] = React.useState<SandboxResourceLimit | null>(null);
|
const [limits, setLimits] = React.useState<SandboxResourceLimit | null>(null);
|
||||||
@@ -177,41 +190,51 @@ export function SandboxPanel({ orgId, projectId }: { orgId: string; projectId: s
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card>
|
{canManageInfra ? (
|
||||||
<CardTitle>ساخت محیط ایزوله</CardTitle>
|
<Card>
|
||||||
<CardHint>
|
<CardTitle>ساخت محیط ایزوله</CardTitle>
|
||||||
کد در یک محیط جداگانه اجرا میشود؛ هیچ دستوری روی سرور میزبان اجرا نمیگردد. هر
|
<CardHint>
|
||||||
دستور در دفتر مصرف ثبت میشود.
|
کد در یک محیط جداگانه اجرا میشود؛ هیچ دستوری روی سرور میزبان اجرا نمیگردد. هر
|
||||||
</CardHint>
|
دستور در دفتر مصرف ثبت میشود.
|
||||||
{templates.length === 0 ? (
|
</CardHint>
|
||||||
<p className="mt-3 text-xs text-slate-500">قالبی در دسترس نیست.</p>
|
{templates.length === 0 ? (
|
||||||
) : (
|
<p className="mt-3 text-xs text-slate-500">قالبی در دسترس نیست.</p>
|
||||||
<form
|
) : (
|
||||||
onSubmit={onCreateWorkspace}
|
<form
|
||||||
className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end"
|
onSubmit={onCreateWorkspace}
|
||||||
>
|
className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end"
|
||||||
<div className="flex-1">
|
>
|
||||||
<Select
|
<div className="flex-1">
|
||||||
label="قالب محیط"
|
<Select
|
||||||
value={templateId}
|
label="قالب محیط"
|
||||||
onChange={(e) => setTemplateId(e.target.value)}
|
value={templateId}
|
||||||
options={templates.map((template) => ({
|
onChange={(e) => setTemplateId(e.target.value)}
|
||||||
value: template.id,
|
options={templates.map((template) => ({
|
||||||
label: `${template.name} — ${template.image}`,
|
value: template.id,
|
||||||
}))}
|
label: `${template.name} — ${template.image}`,
|
||||||
/>
|
}))}
|
||||||
</div>
|
/>
|
||||||
<Button type="submit" loading={creating}>
|
</div>
|
||||||
ساخت محیط
|
<Button type="submit" loading={creating}>
|
||||||
</Button>
|
ساخت محیط
|
||||||
</form>
|
</Button>
|
||||||
)}
|
</form>
|
||||||
{templates.length > 0 && (
|
)}
|
||||||
<p className="mt-2 text-xs text-slate-500">
|
{templates.length > 0 && (
|
||||||
{templates.find((t) => t.id === templateId)?.description ?? ""}
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
</p>
|
{templates.find((t) => t.id === templateId)?.description ?? ""}
|
||||||
)}
|
</p>
|
||||||
</Card>
|
)}
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardTitle>محیط اجرا</CardTitle>
|
||||||
|
<CardHint>
|
||||||
|
زیرساخت (کانتینر، پورت و اجرای دستور) تنها توسط مالک سامانه مدیریت میشود. برای
|
||||||
|
اجرای کارها کافی است به مدیر هوش مصنوعی دستور بدهید.
|
||||||
|
</CardHint>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardTitle>محیطهای این پروژه</CardTitle>
|
<CardTitle>محیطهای این پروژه</CardTitle>
|
||||||
@@ -242,23 +265,25 @@ export function SandboxPanel({ orgId, projectId }: { orgId: string; projectId: s
|
|||||||
ارائهدهنده: <span className="ltr-inline">{workspace.provider}</span>
|
ارائهدهنده: <span className="ltr-inline">{workspace.provider}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
{canManageInfra && (
|
||||||
<Button
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
size="sm"
|
<Button
|
||||||
variant={isActive ? "ghost" : "secondary"}
|
size="sm"
|
||||||
onClick={() => onStartSession(workspace)}
|
variant={isActive ? "ghost" : "secondary"}
|
||||||
loading={starting && !isActive}
|
onClick={() => onStartSession(workspace)}
|
||||||
>
|
loading={starting && !isActive}
|
||||||
{isActive ? "انتخابشده" : "انتخاب و شروع نشست"}
|
>
|
||||||
</Button>
|
{isActive ? "انتخابشده" : "انتخاب و شروع نشست"}
|
||||||
<Button
|
</Button>
|
||||||
size="sm"
|
<Button
|
||||||
variant="danger"
|
size="sm"
|
||||||
onClick={() => setPendingDestroy(workspace)}
|
variant="danger"
|
||||||
>
|
onClick={() => setPendingDestroy(workspace)}
|
||||||
حذف
|
>
|
||||||
</Button>
|
حذف
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -266,7 +291,7 @@ export function SandboxPanel({ orgId, projectId }: { orgId: string; projectId: s
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{activeWorkspace && session && (
|
{canManageInfra && activeWorkspace && session && (
|
||||||
<SessionPanel
|
<SessionPanel
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
session={session}
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user