feat(delivery): docker sandbox adapter + owner-only infrastructure (v2 phase A3-1)

The execution boundary for v2. Adds the local docker/podman sandbox adapter
and the hardened host subprocess runner every other delivery adapter will use.

- adapters/process.py: the one place the platform shells out. argv-only (no
  shell), start_new_session + killpg on timeout, concurrent stdout/stderr
  drains with a byte cap, explicit minimal env (never os.environ), and a
  missing binary reported as spawn_failed rather than an exception.
- adapters/docker_sandbox.py: DockerSandboxAdapter over SandboxProviderPort.
  --user 1000:1000, --cap-drop ALL, no-new-privileges, pids/cpu/memory limits,
  --read-only with tmpfs scratch, one bind mount (the work tree, never the git
  dir), labels, no published ports. Path-safe file access; secrets refused in
  `docker exec -e`. Extras for later phases: start_demo_container (loopback
  publish only), list_managed_containers, remove_container.
- registry: SANDBOX_PROVIDER=docker, cached singleton so the workspace registry
  survives create_workspace -> start_session.
- sandbox_gateway worker: reap_orphans() removes labelled containers no live
  session points at, audited as sandbox.reap; run_pass() does both passes.
- sandbox router: every mutating route now requires the platform owner
  (PLAN-v2 §7.7); read-only status routes stay open to org members.
- sandbox-panel: canManageInfra prop (fails closed) hides the create form,
  the session panel and the action buttons from non-owners.

Tests: 346 API (+37), 67 web (+3); ruff, mypy, tsc and lint clean.
Three existing tests now drive the sandbox as the platform owner, since that
is who may touch it.

Report: docs/plan/reports/agent-3-phase-1.md
This commit is contained in:
mjalmoudy94
2026-10-13 23:57:09 +03:30
parent 0cd9194b64
commit a0c8473eb8
15 changed files with 2047 additions and 79 deletions
@@ -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,