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
159 lines
5.2 KiB
Python
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()
|