Files
smart-org/apps/api/app/infrastructure/adapters/registry.py
T
mjalmoudy94 a0c8473eb8 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
2026-10-13 23:57:09 +03:30

159 lines
5.2 KiB
Python

"""Adapter registry.
One place decides which concrete adapter backs each port, driven purely by the
``*_PROVIDER`` environment switches. Everything defaults to ``mock`` so a fresh
clone runs with no credentials — *except* the model provider: ``agy`` (Google
Antigravity) is the only one the platform talks to, and its accounts are added
from the admin console.
Adapters that hold process state (the mock Git forge, the mock port ledger) are
cached as singletons so behaviour is consistent within a process.
"""
from __future__ import annotations
import logging
from functools import lru_cache
from app.core.config import settings
from app.core.errors import ProviderUnavailableError
from app.infrastructure.adapters import mock
logger = logging.getLogger("agy.registry")
@lru_cache
def get_model_provider():
"""The one model adapter: agy over its logged-in account pool."""
configured = settings.ai_default_provider.strip().lower()
if configured != "agy":
# Tolerate a stale ``AI_DEFAULT_PROVIDER`` from before agy became the
# only provider: warn loudly, keep serving with agy either way.
logger.warning(
"AI_DEFAULT_PROVIDER=%r is obsolete — agy is the only supported "
"model provider; using it",
settings.ai_default_provider,
)
from app.infrastructure.adapters.agy import (
AgyModelAdapter,
load_agy_accounts,
report_agy_state,
)
return AgyModelAdapter(loader=load_agy_accounts, report=report_agy_state)
@lru_cache
def get_git_provider():
provider = settings.git_provider.lower()
if provider == "mock":
return mock.MockGitAdapter()
if provider == "gitea":
from app.infrastructure.adapters.gitea import GiteaGitAdapter
return GiteaGitAdapter(
base_url=settings.gitea_internal_url or settings.gitea_url,
admin_user=settings.gitea_admin_user,
admin_token=settings.gitea_admin_token,
)
raise ProviderUnavailableError(
f"GIT_PROVIDER='{provider}' پشتیبانی نمی‌شود. مقادیر مجاز: mock | gitea"
)
@lru_cache
def get_domain_router():
provider = settings.domain_router.lower()
if provider == "mock":
return mock.MockDomainRouterAdapter(
root_domain=settings.preview_root_domain,
port_start=settings.preview_port_start,
port_end=settings.preview_port_end,
)
if provider == "caddy":
from app.infrastructure.adapters.caddy import CaddyDomainRouterAdapter
return CaddyDomainRouterAdapter(
caddyfile_path=settings.caddyfile_path,
root_domain=settings.preview_root_domain,
port_start=settings.preview_port_start,
port_end=settings.preview_port_end,
)
raise ProviderUnavailableError(
f"DOMAIN_ROUTER='{provider}' پشتیبانی نمی‌شود. مقادیر مجاز: mock | caddy"
)
@lru_cache
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
return build_remote_sandbox_adapter(
provider,
daytona_api_key=settings.daytona_api_key,
daytona_api_url=settings.daytona_api_url,
e2b_api_key=settings.e2b_api_key,
)
raise ProviderUnavailableError(
f"SANDBOX_PROVIDER='{provider}' پشتیبانی نمی‌شود. "
"مقادیر مجاز: mock | docker | daytona | e2b"
)
@lru_cache
def get_payment_provider():
provider = settings.payment_provider.lower()
if provider == "mock":
return mock.LocalMockPaymentAdapter()
if provider == "stripe":
from app.infrastructure.adapters.stripe_payments import StripePaymentAdapter
return StripePaymentAdapter(
secret_key=settings.stripe_secret_key,
webhook_secret=settings.stripe_webhook_secret,
)
raise ProviderUnavailableError(
f"PAYMENT_PROVIDER='{provider}' پشتیبانی نمی‌شود. مقادیر مجاز: mock | stripe"
)
@lru_cache
def get_storage():
return mock.InMemoryStorageAdapter()
@lru_cache
def get_vector_store():
return mock.InMemoryVectorStoreAdapter()
@lru_cache
def get_notifier():
return mock.ConsoleNotificationAdapter()
def reset_adapters() -> None:
"""Drop cached singletons. Used by tests that need a clean slate."""
for fn in (
get_model_provider,
get_git_provider,
get_domain_router,
get_sandbox_provider,
get_payment_provider,
get_storage,
get_vector_store,
get_notifier,
):
fn.cache_clear()