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:
@@ -0,0 +1,641 @@
|
||||
"""Local Docker/podman sandbox adapter — the hardened execution boundary (D5).
|
||||
|
||||
One *workspace* container per application is where the AI employees edit files
|
||||
and run commands; a separate *demo* container (created by the delivery service)
|
||||
runs the latest green build. Both are created by this adapter.
|
||||
|
||||
The hardening is not decoration — it is the only thing standing between an LLM
|
||||
that writes arbitrary shell and the host:
|
||||
|
||||
* non-root (``--user 1000:1000``) and ``--cap-drop ALL`` + ``no-new-privileges``
|
||||
so a setuid binary cannot be used to escalate;
|
||||
* ``--read-only`` root filesystem with two ``tmpfs`` scratch mounts, so a
|
||||
compromised process cannot persist anything outside the bind-mounted work dir;
|
||||
* ``--pids-limit`` / ``--cpus`` / ``--memory`` so a fork bomb or a runaway build
|
||||
cannot take the host down;
|
||||
* the **git dir is never mounted** — only the work tree. The platform runs git
|
||||
on the host (see ``git_workspace.py``), so repository credentials never enter
|
||||
the container;
|
||||
* ports are published on ``127.0.0.1`` only, never ``0.0.0.0``.
|
||||
|
||||
The provider binary is configurable (``SANDBOX_DOCKER_BIN``) and the flags used
|
||||
here are the common subset of ``docker`` and ``podman``, so podman works as a
|
||||
drop-in.
|
||||
|
||||
``create_workspace``/``start_session`` are split by the port, but the workspace
|
||||
identity is *encoded in the external id* (``ws-<org>-<project>``) so a restart
|
||||
can rebuild the host paths without a database round-trip. Only the image is
|
||||
kept in a process-local registry; a cold registry falls back to the profile
|
||||
default for the language, which is what a fresh process would want anyway.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.application.ports import (
|
||||
CommandResult,
|
||||
SandboxProviderPort, # noqa: F401 - documented as the implemented protocol
|
||||
SessionInfo,
|
||||
SnapshotInfo,
|
||||
WorkspaceInfo,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.core.errors import ValidationError
|
||||
from app.domain.platform.rules import assert_not_protected_port
|
||||
from app.infrastructure.adapters.process import ProcessResult, run_process
|
||||
|
||||
logger = logging.getLogger("agy.sandbox.docker")
|
||||
|
||||
#: Language → base image. The plan fixes these names; the mapping lives here so
|
||||
#: the delivery service can ask for an image without knowing the table.
|
||||
LANGUAGE_IMAGES: dict[str, str] = {
|
||||
"node": "node:20-bookworm",
|
||||
"javascript": "node:20-bookworm",
|
||||
"typescript": "node:20-bookworm",
|
||||
"python": "python:3.13-bookworm",
|
||||
"php": "php:8.3-cli",
|
||||
"go": "golang:1.23",
|
||||
"golang": "golang:1.23",
|
||||
"static": "debian:bookworm",
|
||||
"html": "debian:bookworm",
|
||||
}
|
||||
FALLBACK_IMAGE = "debian:bookworm"
|
||||
|
||||
#: Role → label value. The reaper and the admin console filter on these.
|
||||
ROLE_WORKSPACE = "workspace"
|
||||
ROLE_DEMO = "demo"
|
||||
ROLE_BUILD = "build"
|
||||
|
||||
LABEL_ORG = "smart-org.org"
|
||||
LABEL_PROJECT = "smart-org.project"
|
||||
LABEL_ROLE = "smart-org.role"
|
||||
|
||||
#: In-container mount point of the working tree.
|
||||
SANDBOX_WORKSPACE_PATH = "/workspace"
|
||||
|
||||
_SAFE_ID = re.compile(r"^[A-Za-z0-9_.-]+$")
|
||||
_SNAPSHOT_NAME = re.compile(r"[^a-z0-9-]+")
|
||||
|
||||
#: Command used to keep a container alive. ``sleep infinity`` is present in
|
||||
#: every image we use (coreutils in debian, busybox in alpine variants).
|
||||
_KEEPALIVE = "sleep infinity"
|
||||
|
||||
|
||||
class PathEscapeError(ValidationError):
|
||||
"""A requested path resolves outside the sandbox working directory."""
|
||||
|
||||
|
||||
def image_for_language(language: str | None) -> str:
|
||||
"""Base image for an app profile language, with a safe fallback."""
|
||||
return LANGUAGE_IMAGES.get((language or "").strip().lower(), FALLBACK_IMAGE)
|
||||
|
||||
|
||||
def container_name(role: str, organization_id: str, project_id: str) -> str:
|
||||
"""Deterministic container name so a restart can find its own containers."""
|
||||
safe = f"smartorg-{role}-{organization_id}-{project_id}".lower()
|
||||
return re.sub(r"[^a-z0-9_.-]", "-", safe)[:200]
|
||||
|
||||
|
||||
def _check_id(value: str, label: str) -> str:
|
||||
clean = (value or "").strip()
|
||||
if not clean or not _SAFE_ID.match(clean):
|
||||
raise ValidationError(f"شناسهٔ {label} نامعتبر است.")
|
||||
return clean
|
||||
|
||||
|
||||
def safe_join(root: str, path: str) -> str:
|
||||
"""Resolve ``path`` under ``root``, refusing any escape.
|
||||
|
||||
``..`` segments, absolute paths and symlinks that point outside the root are
|
||||
all rejected after ``realpath``, so a symlink created inside the sandbox
|
||||
cannot be used to read ``/etc/shadow`` on the host.
|
||||
"""
|
||||
root_real = os.path.realpath(root)
|
||||
relative = (path or "").lstrip("/")
|
||||
candidate = os.path.realpath(os.path.join(root_real, relative))
|
||||
if candidate != root_real and not candidate.startswith(root_real + os.sep):
|
||||
raise PathEscapeError("مسیر درخواستی خارج از محیط اجرا است.")
|
||||
return candidate
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ManagedContainer:
|
||||
"""One container carrying our labels, as reported by the CLI."""
|
||||
|
||||
container_id: str
|
||||
name: str = ""
|
||||
organization_id: str = ""
|
||||
project_id: str = ""
|
||||
role: str = ""
|
||||
status: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _WorkspaceState:
|
||||
organization_id: str
|
||||
project_id: str
|
||||
host_dir: str
|
||||
image: str
|
||||
session_ids: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class DockerSandboxAdapter:
|
||||
"""``SandboxProviderPort`` over the docker/podman CLI.
|
||||
|
||||
``runner`` is injectable so unit tests assert on the exact argv without a
|
||||
container runtime present.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
provider_bin: str | None = None,
|
||||
workspaces_root: str | None = None,
|
||||
default_cpus: float | None = None,
|
||||
default_memory_mb: int | None = None,
|
||||
max_output_bytes: int | None = None,
|
||||
runner: Callable[..., Awaitable[ProcessResult]] = run_process,
|
||||
) -> None:
|
||||
self.bin = (provider_bin or settings.sandbox_docker_bin or "docker").strip()
|
||||
self.workspaces_root = os.path.abspath(
|
||||
workspaces_root or settings.sandbox_workspaces_root
|
||||
)
|
||||
self.default_cpus = float(
|
||||
settings.sandbox_default_cpus if default_cpus is None else default_cpus
|
||||
)
|
||||
self.default_memory_mb = int(
|
||||
settings.sandbox_default_memory_mb if default_memory_mb is None else default_memory_mb
|
||||
)
|
||||
self.max_output_bytes = int(
|
||||
settings.sandbox_max_output_bytes if max_output_bytes is None else max_output_bytes
|
||||
)
|
||||
self._runner = runner
|
||||
self._workspaces: dict[str, _WorkspaceState] = {}
|
||||
#: container id → the workspace state it belongs to (filled by start_session).
|
||||
self._sessions: dict[str, _WorkspaceState] = {}
|
||||
|
||||
# -- helpers -------------------------------------------------------------
|
||||
|
||||
def _workspace_dir(self, organization_id: str, project_id: str) -> str:
|
||||
return os.path.join(self.workspaces_root, organization_id, project_id, "work")
|
||||
|
||||
def _external_id(self, organization_id: str, project_id: str) -> str:
|
||||
return f"ws-{organization_id}-{project_id}"
|
||||
|
||||
@staticmethod
|
||||
def _parse_external_id(external_id: str) -> tuple[str, str] | None:
|
||||
if not external_id.startswith("ws-"):
|
||||
return None
|
||||
parts = external_id[3:].split("-", 1)
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
return parts[0], parts[1]
|
||||
|
||||
def _state_for(self, external_id: str, image: str | None = None) -> _WorkspaceState:
|
||||
"""Look up (or rebuild) the workspace state behind an external id."""
|
||||
state = self._workspaces.get(external_id)
|
||||
if state is not None:
|
||||
return state
|
||||
parsed = self._parse_external_id(external_id)
|
||||
if parsed is None:
|
||||
raise ValidationError("شناسهٔ محیط اجرا نامعتبر است.")
|
||||
organization_id, project_id = parsed
|
||||
state = _WorkspaceState(
|
||||
organization_id=organization_id,
|
||||
project_id=project_id,
|
||||
host_dir=self._workspace_dir(organization_id, project_id),
|
||||
image=image or FALLBACK_IMAGE,
|
||||
)
|
||||
self._workspaces[external_id] = state
|
||||
return state
|
||||
|
||||
def _state_for_session(self, session_external_id: str) -> _WorkspaceState | None:
|
||||
return self._sessions.get(session_external_id)
|
||||
|
||||
async def _docker(
|
||||
self,
|
||||
args: Sequence[str],
|
||||
*,
|
||||
timeout_seconds: float = 120.0,
|
||||
on_output: Callable[[str], Awaitable[None]] | None = None,
|
||||
) -> ProcessResult:
|
||||
return await self._runner(
|
||||
[self.bin, *args],
|
||||
timeout_seconds=timeout_seconds,
|
||||
max_output_bytes=self.max_output_bytes,
|
||||
on_output=on_output,
|
||||
env={},
|
||||
)
|
||||
|
||||
# -- workspaces ----------------------------------------------------------
|
||||
|
||||
async def create_workspace(
|
||||
self, *, organization_id: str, project_id: str, template_id: str, image: str
|
||||
) -> WorkspaceInfo:
|
||||
org = _check_id(organization_id, "سازمان")
|
||||
project = _check_id(project_id, "پروژه")
|
||||
|
||||
host_dir = self._workspace_dir(org, project)
|
||||
# 0700: the workspace holds the tenant's source code. The platform user
|
||||
# owns it; nothing else on the host may read it.
|
||||
os.makedirs(host_dir, exist_ok=True)
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(self.workspaces_root, 0o700)
|
||||
os.chmod(os.path.dirname(host_dir), 0o700)
|
||||
os.chmod(host_dir, 0o700)
|
||||
|
||||
external_id = self._external_id(org, project)
|
||||
self._workspaces[external_id] = _WorkspaceState(
|
||||
organization_id=org,
|
||||
project_id=project,
|
||||
host_dir=host_dir,
|
||||
image=image or FALLBACK_IMAGE,
|
||||
)
|
||||
return WorkspaceInfo(
|
||||
external_id=external_id,
|
||||
status="ready",
|
||||
raw={"host_dir": host_dir, "image": image},
|
||||
)
|
||||
|
||||
async def start_session(self, *, workspace_external_id: str) -> SessionInfo:
|
||||
state = self._state_for(workspace_external_id)
|
||||
name = container_name(ROLE_WORKSPACE, state.organization_id, state.project_id)
|
||||
|
||||
# A restart leaves the old container behind; reuse it rather than
|
||||
# stacking duplicates, and start it if it was stopped.
|
||||
existing = await self._container_id_by_name(name)
|
||||
if existing:
|
||||
await self._docker(["start", existing], timeout_seconds=60)
|
||||
self._sessions[existing] = state
|
||||
state.session_ids.append(existing)
|
||||
return SessionInfo(external_id=existing, status="running")
|
||||
|
||||
result = await self._docker(
|
||||
[
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
name,
|
||||
# --- isolation -------------------------------------------------
|
||||
"--user",
|
||||
"1000:1000",
|
||||
"--cap-drop",
|
||||
"ALL",
|
||||
"--security-opt",
|
||||
"no-new-privileges",
|
||||
"--pids-limit",
|
||||
"512",
|
||||
"--cpus",
|
||||
str(self.default_cpus),
|
||||
"--memory",
|
||||
f"{self.default_memory_mb}m",
|
||||
# --- immutable root, scratch in tmpfs --------------------------
|
||||
"--read-only",
|
||||
"--tmpfs",
|
||||
"/tmp",
|
||||
"--tmpfs",
|
||||
"/home/app",
|
||||
# --- only the work tree, never the git dir ---------------------
|
||||
"-v",
|
||||
f"{state.host_dir}:{SANDBOX_WORKSPACE_PATH}",
|
||||
"-w",
|
||||
SANDBOX_WORKSPACE_PATH,
|
||||
"--label",
|
||||
f"{LABEL_ORG}={state.organization_id}",
|
||||
"--label",
|
||||
f"{LABEL_PROJECT}={state.project_id}",
|
||||
"--label",
|
||||
f"{LABEL_ROLE}={ROLE_WORKSPACE}",
|
||||
# Default bridge network: package installs need egress. No
|
||||
# published ports — the container is reached with ``exec``.
|
||||
state.image,
|
||||
"sh",
|
||||
"-lc",
|
||||
_KEEPALIVE,
|
||||
],
|
||||
timeout_seconds=180,
|
||||
)
|
||||
if not result.ok:
|
||||
raise ValidationError("راهاندازی محیط اجرا ناموفق بود.")
|
||||
|
||||
container_id = result.stdout.strip().splitlines()[-1].strip() if result.stdout else ""
|
||||
if not container_id:
|
||||
raise ValidationError("راهاندازی محیط اجرا ناموفق بود.")
|
||||
self._sessions[container_id] = state
|
||||
state.session_ids.append(container_id)
|
||||
return SessionInfo(
|
||||
external_id=container_id,
|
||||
status="running",
|
||||
raw={"name": name, "image": state.image},
|
||||
)
|
||||
|
||||
async def _container_id_by_name(self, name: str) -> str | None:
|
||||
result = await self._docker(
|
||||
["ps", "-a", "--filter", f"name=^{name}$", "--format", "{{.ID}}"],
|
||||
timeout_seconds=30,
|
||||
)
|
||||
if not result.ok or not result.stdout.strip():
|
||||
return None
|
||||
return result.stdout.strip().splitlines()[0].strip()
|
||||
|
||||
# -- commands ------------------------------------------------------------
|
||||
|
||||
async def run_command(
|
||||
self,
|
||||
*,
|
||||
session_external_id: str,
|
||||
command: str,
|
||||
timeout_seconds: int,
|
||||
max_output_bytes: int,
|
||||
env: dict[str, str] | None = None,
|
||||
cwd: str | None = None,
|
||||
on_output: Callable[[str], Awaitable[None]] | None = None,
|
||||
) -> CommandResult:
|
||||
"""``docker exec`` the command inside the container.
|
||||
|
||||
``cwd``/``on_output`` are adapter-level extras used by the engine; the
|
||||
port signature is unchanged.
|
||||
|
||||
``env`` is passed with ``-e``. Keys that *look* like secrets are refused
|
||||
outright: argv is visible in ``ps`` and in the audit log, and the whole
|
||||
point of the design is that credentials never travel that way.
|
||||
"""
|
||||
cleaned = (command or "").strip()
|
||||
if not cleaned:
|
||||
raise ValidationError("دستور نمیتواند خالی باشد.")
|
||||
|
||||
env_flags: list[str] = []
|
||||
for key, value in (env or {}).items():
|
||||
if _looks_like_secret(key):
|
||||
raise ValidationError(
|
||||
"متغیرهای محیطی حاوی اطلاعات محرمانه مجاز نیستند."
|
||||
)
|
||||
env_flags.extend(["-e", f"{key}={value}"])
|
||||
|
||||
workdir = cwd or SANDBOX_WORKSPACE_PATH
|
||||
args = [
|
||||
"exec",
|
||||
"-w",
|
||||
workdir,
|
||||
*env_flags,
|
||||
session_external_id,
|
||||
"sh",
|
||||
"-lc",
|
||||
cleaned,
|
||||
]
|
||||
result = await self._runner(
|
||||
[self.bin, *args],
|
||||
timeout_seconds=float(timeout_seconds),
|
||||
max_output_bytes=int(max_output_bytes),
|
||||
on_output=on_output,
|
||||
env={},
|
||||
)
|
||||
return CommandResult(
|
||||
exit_code=result.exit_code,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
duration_ms=result.duration_ms,
|
||||
truncated=result.truncated,
|
||||
)
|
||||
|
||||
# -- files ---------------------------------------------------------------
|
||||
|
||||
def _host_path(self, session_external_id: str, path: str) -> str:
|
||||
state = self._state_for_session(session_external_id)
|
||||
if state is None:
|
||||
raise ValidationError("نشست اجرا یافت نشد.")
|
||||
# Container paths under /workspace map 1:1 onto the bind-mounted host
|
||||
# dir; anything else is not reachable through this adapter.
|
||||
relative = (path or "").lstrip("/")
|
||||
if relative.startswith("workspace"):
|
||||
relative = relative[len("workspace") :].lstrip("/")
|
||||
return safe_join(state.host_dir, relative)
|
||||
|
||||
async def read_file(self, *, session_external_id: str, path: str) -> str:
|
||||
host_path = self._host_path(session_external_id, path)
|
||||
if not os.path.isfile(host_path):
|
||||
return ""
|
||||
with open(host_path, encoding="utf-8", errors="replace") as handle:
|
||||
return handle.read()
|
||||
|
||||
async def write_file(self, *, session_external_id: str, path: str, content: str) -> None:
|
||||
host_path = self._host_path(session_external_id, path)
|
||||
os.makedirs(os.path.dirname(host_path) or ".", exist_ok=True)
|
||||
with open(host_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(content)
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo:
|
||||
"""``docker commit`` the container.
|
||||
|
||||
Supported, but documented as a heavy operation: it captures the whole
|
||||
root filesystem, and the work tree lives on a bind mount that is *not*
|
||||
included. Snapshots are therefore useful for the base image state only.
|
||||
"""
|
||||
state = self._state_for_session(session_external_id)
|
||||
slug = _SNAPSHOT_NAME.sub("-", (name or "snapshot").lower()).strip("-") or "snapshot"
|
||||
org = state.organization_id if state else "unknown"
|
||||
tag = f"smartorg-snapshot-{org}-{slug}"[:120]
|
||||
result = await self._docker(
|
||||
["commit", session_external_id, tag], timeout_seconds=300
|
||||
)
|
||||
if not result.ok:
|
||||
raise ValidationError("تهیهٔ اسنپشات ناموفق بود.")
|
||||
return SnapshotInfo(external_id=tag, name=name)
|
||||
|
||||
async def pause(self, *, session_external_id: str) -> None:
|
||||
await self._docker(["stop", session_external_id], timeout_seconds=60)
|
||||
|
||||
async def resume(self, *, session_external_id: str) -> SessionInfo:
|
||||
result = await self._docker(["start", session_external_id], timeout_seconds=60)
|
||||
return SessionInfo(
|
||||
external_id=session_external_id,
|
||||
status="running" if result.ok else "error",
|
||||
)
|
||||
|
||||
async def destroy(self, *, session_external_id: str) -> None:
|
||||
# ``-f`` covers running and stopped; ``-v`` removes anonymous volumes so
|
||||
# a destroyed workspace does not leave data behind on the host.
|
||||
await self._docker(["rm", "-f", "-v", session_external_id], timeout_seconds=60)
|
||||
self._sessions.pop(session_external_id, None)
|
||||
|
||||
async def get_preview_url(self, *, session_external_id: str, port: int) -> str:
|
||||
# Loopback only, and never an internal platform port. The public hostname
|
||||
# is a domain-layer concern (Phase 4), not the sandbox's.
|
||||
assert_not_protected_port(int(port))
|
||||
return f"http://127.0.0.1:{int(port)}"
|
||||
|
||||
# -- demo / build containers (used by the delivery service) --------------
|
||||
|
||||
async def start_demo_container(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
project_id: str,
|
||||
image: str,
|
||||
host_port: int,
|
||||
app_port: int,
|
||||
command: str,
|
||||
work_dir: str,
|
||||
cpus: float | None = None,
|
||||
memory_mb: int | None = None,
|
||||
name_suffix: str = "",
|
||||
) -> str:
|
||||
"""Start a demo container and return its id.
|
||||
|
||||
Blue/green is implemented by the caller: start on a temporary port, wait
|
||||
for the healthcheck, switch the route, then stop the previous container.
|
||||
Publishing on ``127.0.0.1`` means only Caddy on this host can reach it.
|
||||
"""
|
||||
org = _check_id(organization_id, "سازمان")
|
||||
project = _check_id(project_id, "پروژه")
|
||||
assert_not_protected_port(int(host_port))
|
||||
name = container_name(ROLE_DEMO, org, project)
|
||||
if name_suffix:
|
||||
name = f"{name}-{_SNAPSHOT_NAME.sub('-', name_suffix.lower()).strip('-')}"[:200]
|
||||
|
||||
result = await self._docker(
|
||||
[
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
name,
|
||||
"--user",
|
||||
"1000:1000",
|
||||
"--cap-drop",
|
||||
"ALL",
|
||||
"--security-opt",
|
||||
"no-new-privileges",
|
||||
"--pids-limit",
|
||||
"512",
|
||||
"--cpus",
|
||||
str(cpus if cpus is not None else self.default_cpus),
|
||||
"--memory",
|
||||
f"{int(memory_mb if memory_mb is not None else self.default_memory_mb)}m",
|
||||
"--read-only",
|
||||
"--tmpfs",
|
||||
"/tmp",
|
||||
"--tmpfs",
|
||||
"/home/app",
|
||||
"-v",
|
||||
f"{work_dir}:{SANDBOX_WORKSPACE_PATH}",
|
||||
"-w",
|
||||
SANDBOX_WORKSPACE_PATH,
|
||||
# Loopback publish only — never 0.0.0.0.
|
||||
"-p",
|
||||
f"127.0.0.1:{int(host_port)}:{int(app_port)}",
|
||||
"--label",
|
||||
f"{LABEL_ORG}={org}",
|
||||
"--label",
|
||||
f"{LABEL_PROJECT}={project}",
|
||||
"--label",
|
||||
f"{LABEL_ROLE}={ROLE_DEMO}",
|
||||
image,
|
||||
"sh",
|
||||
"-lc",
|
||||
command or _KEEPALIVE,
|
||||
],
|
||||
timeout_seconds=180,
|
||||
)
|
||||
if not result.ok or not result.stdout.strip():
|
||||
raise ValidationError("راهاندازی نسخهٔ نمایشی ناموفق بود.")
|
||||
return result.stdout.strip().splitlines()[-1].strip()
|
||||
|
||||
# -- maintenance ---------------------------------------------------------
|
||||
|
||||
async def list_managed_containers(self) -> list[ManagedContainer]:
|
||||
"""Every container carrying our labels, running or not.
|
||||
|
||||
Used by the sandbox-gateway worker to find orphans: a container whose
|
||||
session row is gone is still holding CPU and disk.
|
||||
"""
|
||||
result = await self._docker(
|
||||
[
|
||||
"ps",
|
||||
"-a",
|
||||
"--filter",
|
||||
f"label={LABEL_ROLE}",
|
||||
"--format",
|
||||
"{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Labels}}",
|
||||
],
|
||||
timeout_seconds=30,
|
||||
)
|
||||
if not result.ok:
|
||||
return []
|
||||
|
||||
containers: list[ManagedContainer] = []
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
labels = _parse_labels(parts[3])
|
||||
containers.append(
|
||||
ManagedContainer(
|
||||
container_id=parts[0].strip(),
|
||||
name=parts[1].strip(),
|
||||
status=parts[2].strip(),
|
||||
organization_id=labels.get(LABEL_ORG, ""),
|
||||
project_id=labels.get(LABEL_PROJECT, ""),
|
||||
role=labels.get(LABEL_ROLE, ""),
|
||||
)
|
||||
)
|
||||
return containers
|
||||
|
||||
async def remove_container(self, container_id: str) -> bool:
|
||||
result = await self._docker(["rm", "-f", "-v", container_id], timeout_seconds=60)
|
||||
self._sessions.pop(container_id, None)
|
||||
return result.ok
|
||||
|
||||
def host_dir_for(self, organization_id: str, project_id: str) -> str:
|
||||
"""Absolute host path of a project's working tree."""
|
||||
return self._workspace_dir(
|
||||
_check_id(organization_id, "سازمان"), _check_id(project_id, "پروژه")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def binary_available(binary: str | None = None) -> bool:
|
||||
"""True when the container runtime CLI is on PATH."""
|
||||
return shutil.which((binary or settings.sandbox_docker_bin or "docker").strip()) is not None
|
||||
|
||||
|
||||
def _parse_labels(raw: str) -> dict[str, str]:
|
||||
labels: dict[str, str] = {}
|
||||
for item in (raw or "").split(","):
|
||||
if "=" not in item:
|
||||
continue
|
||||
key, _, value = item.partition("=")
|
||||
labels[key.strip()] = value.strip()
|
||||
return labels
|
||||
|
||||
|
||||
def _looks_like_secret(key: str) -> bool:
|
||||
from app.core.redact import is_sensitive_key
|
||||
|
||||
return is_sensitive_key(key)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FALLBACK_IMAGE",
|
||||
"LABEL_ORG",
|
||||
"LABEL_PROJECT",
|
||||
"LABEL_ROLE",
|
||||
"LANGUAGE_IMAGES",
|
||||
"ROLE_BUILD",
|
||||
"ROLE_DEMO",
|
||||
"ROLE_WORKSPACE",
|
||||
"SANDBOX_WORKSPACE_PATH",
|
||||
"DockerSandboxAdapter",
|
||||
"ManagedContainer",
|
||||
"PathEscapeError",
|
||||
"container_name",
|
||||
"image_for_language",
|
||||
"safe_join",
|
||||
]
|
||||
@@ -0,0 +1,274 @@
|
||||
"""The one place the platform shells out to a host binary.
|
||||
|
||||
Nothing else in the codebase may call ``subprocess`` or ``os.system``. Every
|
||||
adapter that needs an external binary (docker/podman, git, caddy reload) goes
|
||||
through :func:`run_process`, which exists to make four failure modes impossible
|
||||
rather than merely unlikely:
|
||||
|
||||
**Shell injection.** The command is an ``argv`` sequence handed straight to
|
||||
``asyncio.create_subprocess_exec``. There is no ``shell=True`` path and no
|
||||
string interpolation into a shell, so a task id or a branch name can never
|
||||
become a second command.
|
||||
|
||||
**Orphaned children.** The child starts in its own session
|
||||
(``start_new_session=True``), so a timeout kills the whole *process group*. A
|
||||
``docker build`` that spawns helpers, or a ``git`` that runs a credential
|
||||
helper, dies with its parent instead of lingering as a stray process holding a
|
||||
lock.
|
||||
|
||||
**Pipe deadlock.** stdout and stderr are drained concurrently by two tasks.
|
||||
Reading one to completion before touching the other is the classic 64 KB
|
||||
deadlock: the child blocks writing stderr, we block reading stdout, and both
|
||||
wait forever. ``communicate()`` is not used because we want a byte cap and a
|
||||
streaming callback.
|
||||
|
||||
**Secret inheritance.** The child gets an explicit, minimal environment built
|
||||
from :func:`base_env`, never ``os.environ`` wholesale. The platform process
|
||||
holds the JWT secret, the database DSN and every provider key; a subprocess that
|
||||
inherits them can leak them through a stack trace, a build log or a core dump.
|
||||
|
||||
Output is capped in bytes while streaming, so a runaway build cannot exhaust
|
||||
memory. The cap is applied *after* the callback sees the chunk, so live logs
|
||||
still show everything that arrived.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
#: Environment variables a child may inherit from the platform process. Anything
|
||||
#: not listed is dropped. Deliberately tiny.
|
||||
_BASE_ENV_KEYS: tuple[str, ...] = ("PATH", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR")
|
||||
|
||||
#: Fallback PATH when the parent has none (systemd units often do not).
|
||||
_FALLBACK_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
|
||||
#: Read size per pipe chunk. 64 KiB is the pipe buffer size, so this drains
|
||||
#: promptly without spinning.
|
||||
_CHUNK_BYTES = 65_536
|
||||
|
||||
#: How long a child gets to die politely after SIGTERM before SIGKILL.
|
||||
_TERM_GRACE_SECONDS = 5.0
|
||||
|
||||
#: An argv element longer than this is almost certainly a mistake (a base64 blob
|
||||
#: pasted into a command line). Refused rather than silently truncated.
|
||||
MAX_ARGV_ELEMENT_CHARS = 100_000
|
||||
|
||||
|
||||
class ProcessError(RuntimeError):
|
||||
"""A host command could not be started at all (binary missing, not executable)."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ProcessResult:
|
||||
"""Outcome of one host command.
|
||||
|
||||
``argv`` is retained so a test can assert on the *exact* argument vector —
|
||||
which is how the hardening flags and the "no credential in argv" rule are
|
||||
verified.
|
||||
"""
|
||||
|
||||
argv: tuple[str, ...]
|
||||
exit_code: int
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
duration_ms: int = 0
|
||||
timed_out: bool = False
|
||||
truncated: bool = False
|
||||
#: True when the binary itself could not be executed (ENOENT/EACCES).
|
||||
spawn_failed: bool = False
|
||||
spawn_error: str = ""
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.exit_code == 0 and not self.timed_out and not self.spawn_failed
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _DrainResult:
|
||||
data: bytes = b""
|
||||
truncated: bool = False
|
||||
lines: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def base_env(extra: Mapping[str, str] | None = None) -> dict[str, str]:
|
||||
"""Minimal child environment: a few safe keys plus explicit overrides.
|
||||
|
||||
Callers add what they need (``HOME`` for git, ``GIT_CONFIG_NOSYSTEM=1``) and
|
||||
nothing else is inherited.
|
||||
"""
|
||||
env = {key: os.environ[key] for key in _BASE_ENV_KEYS if key in os.environ}
|
||||
env.setdefault("PATH", _FALLBACK_PATH)
|
||||
if extra:
|
||||
env.update({str(k): str(v) for k, v in extra.items()})
|
||||
return env
|
||||
|
||||
|
||||
def _validate_argv(argv: Sequence[str]) -> tuple[str, ...]:
|
||||
if not argv:
|
||||
raise ValueError("argv must not be empty")
|
||||
cleaned: list[str] = []
|
||||
for index, element in enumerate(argv):
|
||||
if not isinstance(element, str):
|
||||
raise TypeError(f"argv[{index}] must be str, got {type(element).__name__}")
|
||||
if "\x00" in element:
|
||||
raise ValueError(f"argv[{index}] contains a NUL byte")
|
||||
if len(element) > MAX_ARGV_ELEMENT_CHARS:
|
||||
raise ValueError(
|
||||
f"argv[{index}] is {len(element)} chars; "
|
||||
f"the limit is {MAX_ARGV_ELEMENT_CHARS}"
|
||||
)
|
||||
cleaned.append(element)
|
||||
return tuple(cleaned)
|
||||
|
||||
|
||||
async def _drain(
|
||||
stream: asyncio.StreamReader | None,
|
||||
*,
|
||||
cap_bytes: int,
|
||||
on_output: Callable[[str], Awaitable[None]] | None,
|
||||
) -> _DrainResult:
|
||||
"""Read a pipe to EOF, keeping at most ``cap_bytes`` and streaming every chunk."""
|
||||
result = _DrainResult()
|
||||
if stream is None:
|
||||
return result
|
||||
|
||||
buffer = bytearray()
|
||||
truncated = False
|
||||
while True:
|
||||
chunk = await stream.read(_CHUNK_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
if len(buffer) < cap_bytes:
|
||||
remaining = cap_bytes - len(buffer)
|
||||
buffer.extend(chunk[:remaining])
|
||||
if len(chunk) > remaining:
|
||||
truncated = True
|
||||
else:
|
||||
truncated = True
|
||||
if on_output is not None:
|
||||
# The callback sees the *raw* chunk even past the cap: a live log
|
||||
# should not stop scrolling just because we stopped storing.
|
||||
with contextlib.suppress(Exception):
|
||||
await on_output(chunk.decode("utf-8", "replace"))
|
||||
|
||||
result.data = bytes(buffer)
|
||||
result.truncated = truncated
|
||||
return result
|
||||
|
||||
|
||||
async def _terminate(proc: asyncio.subprocess.Process) -> None:
|
||||
"""SIGTERM the child's process group, then SIGKILL if it will not go."""
|
||||
if proc.returncode is not None:
|
||||
return
|
||||
|
||||
def _signal_group(sig: int) -> None:
|
||||
# The child is a session leader (``start_new_session=True``), so its pid
|
||||
# *is* its process-group id. Fall back to the pid on platforms without
|
||||
# process groups.
|
||||
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
||||
if hasattr(os, "killpg"):
|
||||
os.killpg(proc.pid, sig)
|
||||
else: # pragma: no cover - POSIX is the only supported target
|
||||
proc.send_signal(sig)
|
||||
|
||||
_signal_group(signal.SIGTERM)
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=_TERM_GRACE_SECONDS)
|
||||
return
|
||||
except TimeoutError:
|
||||
pass
|
||||
_signal_group(signal.SIGKILL)
|
||||
with contextlib.suppress(Exception):
|
||||
await proc.wait()
|
||||
|
||||
|
||||
async def run_process(
|
||||
argv: Sequence[str],
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
env: Mapping[str, str] | None = None,
|
||||
timeout_seconds: float = 120.0,
|
||||
max_output_bytes: int = 200_000,
|
||||
on_output: Callable[[str], Awaitable[None]] | None = None,
|
||||
stdin_data: bytes | None = None,
|
||||
) -> ProcessResult:
|
||||
"""Run ``argv`` and return its bounded, timed result.
|
||||
|
||||
Never raises for a non-zero exit or a timeout — those are ordinary outcomes
|
||||
the caller inspects. It *does* raise :class:`ProcessError` when the binary
|
||||
cannot be started, because that is a deployment fault, not a command result.
|
||||
|
||||
``stdin_data`` is written and closed immediately; the platform never hands a
|
||||
child an interactive terminal.
|
||||
"""
|
||||
cleaned = _validate_argv(argv)
|
||||
started = time.perf_counter()
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cleaned,
|
||||
cwd=cwd,
|
||||
env=base_env(env),
|
||||
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
except (FileNotFoundError, NotADirectoryError, PermissionError, OSError) as exc:
|
||||
return ProcessResult(
|
||||
argv=cleaned,
|
||||
exit_code=127,
|
||||
stderr=str(exc),
|
||||
duration_ms=int((time.perf_counter() - started) * 1000),
|
||||
spawn_failed=True,
|
||||
spawn_error=type(exc).__name__,
|
||||
)
|
||||
|
||||
if stdin_data is not None and proc.stdin is not None:
|
||||
with contextlib.suppress(BrokenPipeError, ConnectionResetError):
|
||||
proc.stdin.write(stdin_data)
|
||||
await proc.stdin.drain()
|
||||
with contextlib.suppress(Exception):
|
||||
proc.stdin.close()
|
||||
|
||||
stdout_task = asyncio.create_task(
|
||||
_drain(proc.stdout, cap_bytes=max_output_bytes, on_output=on_output)
|
||||
)
|
||||
stderr_task = asyncio.create_task(
|
||||
_drain(proc.stderr, cap_bytes=max_output_bytes, on_output=None)
|
||||
)
|
||||
|
||||
timed_out = False
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=max(1.0, float(timeout_seconds)))
|
||||
except TimeoutError:
|
||||
timed_out = True
|
||||
await _terminate(proc)
|
||||
|
||||
stdout_result, stderr_result = await asyncio.gather(stdout_task, stderr_task)
|
||||
|
||||
return ProcessResult(
|
||||
argv=cleaned,
|
||||
exit_code=proc.returncode if proc.returncode is not None else -1,
|
||||
stdout=stdout_result.data.decode("utf-8", "replace"),
|
||||
stderr=stderr_result.data.decode("utf-8", "replace"),
|
||||
duration_ms=int((time.perf_counter() - started) * 1000),
|
||||
timed_out=timed_out,
|
||||
truncated=stdout_result.truncated or stderr_result.truncated,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAX_ARGV_ELEMENT_CHARS",
|
||||
"ProcessError",
|
||||
"ProcessResult",
|
||||
"base_env",
|
||||
"run_process",
|
||||
]
|
||||
@@ -89,6 +89,13 @@ def get_sandbox_provider():
|
||||
provider = settings.sandbox_provider.lower()
|
||||
if provider == "mock":
|
||||
return mock.LocalMockSandboxAdapter(max_output_bytes=settings.sandbox_max_output_bytes)
|
||||
if provider == "docker":
|
||||
# Local docker/podman. One instance per process so the workspace
|
||||
# registry (external id → host dir) stays consistent between
|
||||
# ``create_workspace`` and the ``start_session`` that follows it.
|
||||
from app.infrastructure.adapters.docker_sandbox import DockerSandboxAdapter
|
||||
|
||||
return DockerSandboxAdapter()
|
||||
if provider in {"daytona", "e2b"}:
|
||||
from app.infrastructure.adapters.sandbox_remote import build_remote_sandbox_adapter
|
||||
|
||||
@@ -99,7 +106,8 @@ def get_sandbox_provider():
|
||||
e2b_api_key=settings.e2b_api_key,
|
||||
)
|
||||
raise ProviderUnavailableError(
|
||||
f"SANDBOX_PROVIDER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | daytona | e2b"
|
||||
f"SANDBOX_PROVIDER='{provider}' پشتیبانی نمیشود. "
|
||||
"مقادیر مجاز: mock | docker | daytona | e2b"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -198,6 +198,29 @@ class SandboxSessionRepository:
|
||||
)
|
||||
return [_session(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def list_active_external_ids(self, *, limit: int = 5_000) -> set[str]:
|
||||
"""External ids of every session that is not stopped.
|
||||
|
||||
The orphan reaper's query: a container whose id is not in this set has
|
||||
no live row pointing at it, so it is holding resources for nobody.
|
||||
"""
|
||||
stmt = (
|
||||
select(SandboxSessionRow.external_session_id)
|
||||
.where(
|
||||
SandboxSessionRow.status.in_(
|
||||
[
|
||||
str(SessionStatus.STARTING),
|
||||
str(SessionStatus.RUNNING),
|
||||
str(SessionStatus.PAUSED),
|
||||
]
|
||||
),
|
||||
SandboxSessionRow.external_session_id.is_not(None),
|
||||
)
|
||||
.limit(min(limit, 20_000))
|
||||
)
|
||||
rows = (await self.session.execute(stmt)).scalars().all()
|
||||
return {str(value) for value in rows if value}
|
||||
|
||||
async def create(self, sandbox_session: SandboxSession) -> SandboxSession:
|
||||
row = SandboxSessionRow(
|
||||
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
|
||||
|
||||
@@ -4,8 +4,17 @@ Note what is **absent**: there is no route that accepts a shell command and runs
|
||||
it on the host. Every execution goes to ``SandboxProviderPort``, is bounded by a
|
||||
policy timeout and output cap, and is metered into the usage ledger.
|
||||
|
||||
Running a command requires ``sandbox.use``; tearing a workspace down requires
|
||||
``projects.manage`` because it destroys work.
|
||||
**Who may call what (v2).** Containers, ports and command output are
|
||||
*infrastructure*, and v2 gives infrastructure to the platform owner alone
|
||||
(PLAN-v2 §7.7): a customer only ever gives orders to the AI. So every mutating
|
||||
route here — create/start/stop a workspace or session, run a command, read or
|
||||
write a file, snapshot, mint a preview URL, set limits — requires the platform
|
||||
owner. The read-only status routes (templates, effective limits, workspace list,
|
||||
the command log) stay open to org members, because the project page shows them.
|
||||
|
||||
The org-scoped prefix is kept even for owner-only routes: it is what scopes the
|
||||
audit trail and the tenancy check, and it means the platform owner must
|
||||
*deliberately* be a member of the org they are operating on.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,8 +28,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.sandbox_service import SandboxService
|
||||
from app.core.database import get_session
|
||||
from app.core.errors import PermissionDeniedError
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.identity.models import CAN_MANAGE_PROJECTS, CAN_USE_SANDBOX
|
||||
from app.domain.sandbox.models import NetworkPolicy
|
||||
from app.interfaces.http.deps import (
|
||||
OrgContext,
|
||||
@@ -32,6 +41,19 @@ from app.interfaces.http.deps import (
|
||||
router = APIRouter(prefix="/organizations/{org_id}/sandbox", tags=["sandbox"])
|
||||
|
||||
|
||||
def _require_platform_owner(context: OrgContext) -> None:
|
||||
"""Infrastructure belongs to the platform owner (PLAN-v2 §7.7).
|
||||
|
||||
Deliberately *not* an org-capability check: an org admin running a command
|
||||
in a container is exactly the "users touch infrastructure" behaviour v2
|
||||
removes.
|
||||
"""
|
||||
if not context.user.is_platform_owner:
|
||||
raise PermissionDeniedError(
|
||||
"دسترسی به زیرساخت فقط برای مالک سامانه امکانپذیر است."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -181,7 +203,7 @@ async def set_limits(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> ResourceLimitOut:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
_require_platform_owner(context)
|
||||
limit = await service.set_limits(
|
||||
scope=payload.scope, scope_id=payload.scope_id or context.org_id,
|
||||
cpu=payload.cpu, memory_mb=payload.memory_mb, disk_mb=payload.disk_mb,
|
||||
@@ -211,7 +233,7 @@ async def create_workspace(
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WorkspaceOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
_require_platform_owner(context)
|
||||
|
||||
workspace = await service.create_workspace(
|
||||
organization_id=context.org_id,
|
||||
@@ -264,7 +286,7 @@ async def destroy_workspace(
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WorkspaceOut:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
_require_platform_owner(context)
|
||||
workspace = await service.destroy_workspace(
|
||||
organization_id=context.org_id, workspace_id=workspace_id
|
||||
)
|
||||
@@ -295,7 +317,7 @@ async def start_session(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> SessionOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
_require_platform_owner(context)
|
||||
sandbox_session = await service.start_session(
|
||||
organization_id=context.org_id, workspace_id=payload.workspace_id
|
||||
)
|
||||
@@ -312,7 +334,7 @@ async def stop_session(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> SessionOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
_require_platform_owner(context)
|
||||
sandbox_session = await service.stop_session(
|
||||
organization_id=context.org_id, session_id=session_id
|
||||
)
|
||||
@@ -337,7 +359,7 @@ async def run_command(
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CommandRunOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
_require_platform_owner(context)
|
||||
|
||||
run, _result = await service.run_command(
|
||||
organization_id=context.org_id,
|
||||
@@ -389,7 +411,7 @@ async def read_file(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> FileReadOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
_require_platform_owner(context)
|
||||
content = await service.read_file(
|
||||
organization_id=context.org_id, session_id=session_id, path=payload.path
|
||||
)
|
||||
@@ -403,7 +425,7 @@ async def write_file(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> None:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
_require_platform_owner(context)
|
||||
await service.write_file(
|
||||
organization_id=context.org_id, session_id=session_id,
|
||||
path=payload.path, content=payload.content,
|
||||
@@ -426,7 +448,7 @@ async def create_snapshot(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> SnapshotOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
_require_platform_owner(context)
|
||||
snapshot = await service.snapshot(
|
||||
organization_id=context.org_id, session_id=session_id, name=payload.name
|
||||
)
|
||||
@@ -442,7 +464,7 @@ async def get_preview_url(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> PreviewUrlOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
_require_platform_owner(context)
|
||||
preview = await service.get_preview_url(
|
||||
organization_id=context.org_id, session_id=session_id, port=port
|
||||
)
|
||||
|
||||
@@ -86,7 +86,7 @@ def _sandbox_gateway_pass(args: argparse.Namespace):
|
||||
gateway = SandboxGatewayWorker()
|
||||
|
||||
async def one_pass() -> WorkerOutcome:
|
||||
return await gateway.reap(
|
||||
return await gateway.run_pass(
|
||||
idle_minutes=args.idle_minutes, limit=max(args.limit, 200)
|
||||
)
|
||||
|
||||
|
||||
@@ -23,10 +23,19 @@ from datetime import timedelta
|
||||
from app.application.container import build_sandbox_service
|
||||
from app.application.ports.realtime import emit_sandbox_reaped
|
||||
from app.core.config import settings
|
||||
from app.domain.audit.models import AuditAction, AuditEvent
|
||||
from app.domain.audit.models import (
|
||||
AuditAction,
|
||||
AuditEvent,
|
||||
ErrorSeverity,
|
||||
ErrorSource,
|
||||
)
|
||||
from app.domain.base import utcnow
|
||||
from app.infrastructure.error_log import record_error
|
||||
from app.infrastructure.repositories.audit import AuditRepository
|
||||
from app.infrastructure.repositories.sandbox import WorkspaceRepository
|
||||
from app.infrastructure.repositories.sandbox import (
|
||||
SandboxSessionRepository,
|
||||
WorkspaceRepository,
|
||||
)
|
||||
from app.workers.base import WorkerOutcome, worker_session
|
||||
|
||||
#: Sessions are considered abandoned after this long with no activity.
|
||||
@@ -38,6 +47,22 @@ class SandboxGatewayWorker:
|
||||
|
||||
name = "sandbox-gateway"
|
||||
|
||||
async def run_pass(
|
||||
self, *, idle_minutes: int = DEFAULT_IDLE_MINUTES, limit: int = 200
|
||||
) -> WorkerOutcome:
|
||||
"""One housekeeping pass: idle sessions first, then orphan containers.
|
||||
|
||||
Combined into one pass so a cron entry needs a single invocation and an
|
||||
operator sees one summary line per run.
|
||||
"""
|
||||
outcome = await self.reap(idle_minutes=idle_minutes, limit=limit)
|
||||
orphans = await self.reap_orphans(limit=limit)
|
||||
outcome.processed += orphans.processed
|
||||
outcome.failed += orphans.failed
|
||||
for note in orphans.notes:
|
||||
outcome.note(note)
|
||||
return outcome
|
||||
|
||||
async def reap(
|
||||
self, *, idle_minutes: int = DEFAULT_IDLE_MINUTES, limit: int = 200
|
||||
) -> WorkerOutcome:
|
||||
@@ -98,5 +123,86 @@ class SandboxGatewayWorker:
|
||||
outcome.note("egress policy default = deny_all (set per resource limit)")
|
||||
return outcome
|
||||
|
||||
async def reap_orphans(self, *, limit: int = 200) -> WorkerOutcome:
|
||||
"""Remove labelled containers that no live session row points at.
|
||||
|
||||
``reap`` above stops *sessions*; this stops *containers*. The two are
|
||||
not the same: a session row can be stopped (or the database restored
|
||||
from a backup) while the container keeps running, and a container can be
|
||||
started by a process that then died before writing its row. Without this
|
||||
pass those containers hold CPU, memory and disk forever, because nothing
|
||||
in the application knows they exist.
|
||||
|
||||
Only the container runtime is inspected. A provider without
|
||||
``list_managed_containers`` (the mock, the remote adapters) has nothing
|
||||
to reap and is skipped silently — this is not an error condition.
|
||||
"""
|
||||
outcome = WorkerOutcome(name=self.name)
|
||||
|
||||
from app.infrastructure.adapters.registry import get_sandbox_provider
|
||||
|
||||
provider = get_sandbox_provider()
|
||||
lister = getattr(provider, "list_managed_containers", None)
|
||||
if lister is None:
|
||||
outcome.skipped += 1
|
||||
outcome.note("provider does not expose managed containers; nothing to reap")
|
||||
return outcome
|
||||
|
||||
try:
|
||||
containers = await lister()
|
||||
except Exception as exc: # noqa: BLE001 - a CLI outage must not kill the worker
|
||||
await record_error(
|
||||
kind="sandbox_orphan_list_failed",
|
||||
message=str(exc),
|
||||
exc=exc,
|
||||
source=ErrorSource.WORKER,
|
||||
severity=ErrorSeverity.WARNING,
|
||||
metadata={"worker": self.name},
|
||||
)
|
||||
outcome.failed += 1
|
||||
return outcome
|
||||
|
||||
async with worker_session() as session:
|
||||
live = await SandboxSessionRepository(session).list_active_external_ids()
|
||||
audit = AuditRepository(session)
|
||||
|
||||
reaped = 0
|
||||
for container in containers:
|
||||
if reaped >= limit:
|
||||
break
|
||||
if container.container_id in live:
|
||||
continue
|
||||
# A demo/build container is owned by the delivery pipeline, which
|
||||
# tracks it in its own table. Until that table exists it is left
|
||||
# alone rather than guessed at.
|
||||
if container.role and container.role != "workspace":
|
||||
outcome.skipped += 1
|
||||
continue
|
||||
removed = await provider.remove_container(container.container_id)
|
||||
if not removed:
|
||||
outcome.failed += 1
|
||||
continue
|
||||
reaped += 1
|
||||
outcome.processed += 1
|
||||
outcome.note(f"removed orphan container {container.container_id}")
|
||||
await audit.record(
|
||||
event=AuditEvent(
|
||||
action=AuditAction.SANDBOX_REAP,
|
||||
organization_id=container.organization_id or None,
|
||||
target_type="sandbox_container",
|
||||
target_id=container.container_id,
|
||||
metadata_json={
|
||||
"reason": "orphan_container",
|
||||
"project_id": container.project_id,
|
||||
"role": container.role,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
if not reaped:
|
||||
outcome.skipped += 1
|
||||
|
||||
return outcome
|
||||
|
||||
|
||||
__all__ = ["DEFAULT_IDLE_MINUTES", "SandboxGatewayWorker"]
|
||||
|
||||
@@ -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
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.domain.identity.models import PlatformRole
|
||||
from tests.conftest import auth_header, signup
|
||||
|
||||
|
||||
@@ -34,7 +35,14 @@ async def _platform_seed(seeded):
|
||||
|
||||
async def test_end_to_end_smoke_path(client: AsyncClient):
|
||||
# -- 1. signup -------------------------------------------------------- #
|
||||
signup_body = await signup(client, "smoke@example.com", name="کاربر دود")
|
||||
# The platform owner, not a plain manager: step 6 drives the sandbox
|
||||
# directly, and v2 makes container/command access owner-only
|
||||
# (PLAN-v2 §7.7). The org-scoped prefix still scopes the tenant and the
|
||||
# audit trail, so this is the same path a customer walks minus the
|
||||
# infrastructure step.
|
||||
signup_body = await signup(
|
||||
client, "smoke@example.com", name="کاربر دود", platform_role=PlatformRole.OWNER
|
||||
)
|
||||
token = signup_body["access_token"]
|
||||
headers = auth_header(token)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.domain.base import utcnow
|
||||
from app.domain.identity.models import PlatformRole
|
||||
from app.workers import base as worker_base
|
||||
from app.workers.ai_runtime import AiRuntimeWorker
|
||||
from app.workers.billing import BillingWorker
|
||||
@@ -48,9 +49,18 @@ async def _workers_use_the_test_database(monkeypatch, session_factory):
|
||||
return session_factory
|
||||
|
||||
|
||||
async def _make_org_with_employee(client: AsyncClient, email: str) -> tuple[str, dict, str, str]:
|
||||
"""Return ``(org_id, headers, project_id, employee_id)`` for a fresh tenant."""
|
||||
token = (await signup(client, email))["access_token"]
|
||||
async def _make_org_with_employee(
|
||||
client: AsyncClient,
|
||||
email: str,
|
||||
*,
|
||||
platform_role: PlatformRole = PlatformRole.MANAGER,
|
||||
) -> tuple[str, dict, str, str]:
|
||||
"""Return ``(org_id, headers, project_id, employee_id)`` for a fresh tenant.
|
||||
|
||||
``platform_role`` is a parameter because the sandbox-gateway tests drive
|
||||
container endpoints, which v2 restricts to the platform owner.
|
||||
"""
|
||||
token = (await signup(client, email, platform_role=platform_role))["access_token"]
|
||||
headers = auth_header(token)
|
||||
org_id = (
|
||||
await client.post("/api/organizations", json={"name": "سازمان کارگر"}, headers=headers)
|
||||
@@ -249,7 +259,7 @@ async def test_billing_roll_up_refuses_a_nonsense_period(delta: timedelta):
|
||||
async def test_sandbox_gateway_stops_an_idle_session_and_audits_it(client: AsyncClient):
|
||||
"""An abandoned session must be stopped, and the stop must be auditable."""
|
||||
org_id, headers, project_id, _ = await _make_org_with_employee(
|
||||
client, "worker-gateway@example.com"
|
||||
client, "worker-gateway@example.com", platform_role=PlatformRole.OWNER
|
||||
)
|
||||
template_id = (
|
||||
await client.get(f"/api/organizations/{org_id}/sandbox/templates", headers=headers)
|
||||
@@ -301,7 +311,7 @@ async def test_sandbox_gateway_stops_an_idle_session_and_audits_it(client: Async
|
||||
async def test_sandbox_gateway_leaves_a_fresh_session_alone(client: AsyncClient):
|
||||
"""A session that was just used must not be reaped."""
|
||||
org_id, headers, project_id, _ = await _make_org_with_employee(
|
||||
client, "worker-gateway-fresh@example.com"
|
||||
client, "worker-gateway-fresh@example.com", platform_role=PlatformRole.OWNER
|
||||
)
|
||||
template_id = (
|
||||
await client.get(f"/api/organizations/{org_id}/sandbox/templates", headers=headers)
|
||||
|
||||
Reference in New Issue
Block a user