نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی

مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت.
تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
mjalmoudy94
2026-10-13 00:39:28 +03:30
commit 717e53a7ce
185 changed files with 39910 additions and 0 deletions
@@ -0,0 +1,326 @@
"""Caddy adapter for :class:`DomainRouterPort`.
Publishes tenant preview domains by maintaining a *managed snippet file* that the
main Caddyfile imports, then reloading Caddy. The main Caddyfile is never edited
by this adapter.
Why a snippet instead of editing the Caddyfile
---------------------------------------------
Rewriting an operator's Caddyfile means parsing and re-emitting a file we do not
own; a mistake there takes down every site on the host. A separate file that
Caddy imports by glob keeps the blast radius to tenant preview routes only.
Setup (one line, once)
----------------------
Add this to the Caddyfile and reload Caddy::
import /etc/caddy/agy-preview/*.caddy
The adapter refuses to run if that import is missing, because silently writing a
file that Caddy never reads would report success for a domain that does not
resolve.
Security rules enforced here, not only upstream
-----------------------------------------------
* the host must be a single label under ``PREVIEW_ROOT_DOMAIN``
(:func:`validate_managed_host`) — this is what stops a crafted host from
closing the site block and injecting directives
* the proxy target must be loopback with a numeric port (:func:`validate_proxy_target`)
— SSRF prevention
* protected platform ports are refused (:func:`assert_not_protected_port`)
* the rendered config is validated *before* it is loaded, and every failed
change is rolled back to the previous file
The ``caddy`` binary runs on the platform host. That is a different trust domain
from tenant code: tenant commands only ever run inside a sandbox adapter.
"""
from __future__ import annotations
import asyncio
import re
from pathlib import Path
from app.application.ports import PreviewDomainInfo, ReverseProxyResult
from app.core.errors import ProviderUnavailableError
from app.domain.platform.rules import (
RuleViolation,
assert_not_protected_port,
assert_port_allocatable,
build_host,
validate_managed_host,
validate_proxy_target,
)
_BEGIN = "# >>> agy-managed: {host}"
_END = "# <<< agy-managed: {host}"
_BLOCK_RE = re.compile(
r"# >>> agy-managed: (?P<host>\S+)\n(?P<body>.*?)# <<< agy-managed: (?P=host)\n",
re.DOTALL,
)
_VALIDATE_TIMEOUT_SECONDS = 15
_RELOAD_TIMEOUT_SECONDS = 30
class CaddyDomainRouterAdapter:
"""Reverse-proxy routes managed through a Caddy snippet file."""
def __init__(
self,
*,
caddyfile_path: str,
root_domain: str,
port_start: int = 8080,
port_end: int = 8999,
snippet_dir: str | None = None,
caddy_binary: str = "caddy",
) -> None:
self.caddyfile_path = Path(caddyfile_path)
self.root_domain = root_domain
self.port_start = port_start
self.port_end = port_end
self.caddy_binary = caddy_binary
# Default to a sibling directory of the Caddyfile so the import glob is
# a short, predictable path.
self.snippet_dir = (
Path(snippet_dir)
if snippet_dir
else self.caddyfile_path.parent / "agy-preview"
)
self.snippet_path = self.snippet_dir / "preview.caddy"
# -- port allocation -----------------------------------------------------
async def allocate_port(
self, *, organization_id: str, project_id: str, service_name: str
) -> int:
"""Pick the lowest free port that is not already routed.
The database is the authoritative port ledger
(``PreviewDomainRepository``); this only avoids handing out a port that a
live route already occupies.
"""
in_use = {entry.port for entry in self._parse(self._read_snippet())}
for port in range(self.port_start, self.port_end + 1):
if port in in_use:
continue
try:
assert_port_allocatable(port, start=self.port_start, end=self.port_end)
except RuleViolation:
continue
return port
raise ProviderUnavailableError("پورت آزادی در بازهٔ تعیین‌شده وجود ندارد.")
async def claim_preview_domain(
self,
*,
organization_id: str,
project_id: str,
requested_subdomain: str,
port: int,
) -> PreviewDomainInfo:
host = build_host(requested_subdomain, self.root_domain)
result = await self.configure_reverse_proxy(host=host, port=port)
if not result.ok:
raise RuleViolation(result.message or "پیکربندی دامنه ناموفق بود.")
return PreviewDomainInfo(host=host, port=port, url=result.url, status="active")
# -- routing -------------------------------------------------------------
async def configure_reverse_proxy(self, *, host: str, port: int) -> ReverseProxyResult:
"""Create or replace the route for ``host`` and reload Caddy."""
try:
clean_host = validate_managed_host(host, self.root_domain)
# Raises unless the target is loopback with a numeric port.
validate_proxy_target(f"localhost:{port}")
assert_port_allocatable(port, start=self.port_start, end=self.port_end)
assert_not_protected_port(port)
except RuleViolation as exc:
# A rejected rule is a caller error, not an outage.
return ReverseProxyResult(ok=False, message=str(exc))
if not self._import_is_wired():
return ReverseProxyResult(
ok=False,
message=(
"فایل Caddyfile خط import مربوط به AGY را ندارد. "
f"این خط را اضافه کنید: import {self.snippet_dir}/*.caddy"
),
)
previous = self._read_snippet()
entries = [e for e in self._parse(previous) if e.host != clean_host]
entries.append(_Entry(host=clean_host, port=port))
updated = self._render(entries)
self._write_snippet(updated)
error = await self._validate()
if error is None:
error = await self._reload()
if error is not None:
# Roll back so a failed change never leaves a half-written config.
self._write_snippet(previous)
await self._reload()
return ReverseProxyResult(ok=False, message=error)
return ReverseProxyResult(
ok=True, message="مسیر دامنه ثبت و Caddy بارگذاری مجدد شد.", url=f"https://{clean_host}"
)
async def remove_preview_domain(self, *, domain_id: str) -> None:
"""Remove a route.
``domain_id`` is the host, matching how the application layer calls this
(``remove_preview_domain(domain_id=record["host"])``).
"""
try:
clean_host = validate_managed_host(domain_id, self.root_domain)
except RuleViolation:
# Not one of ours — nothing to remove, and not an error.
return
previous = self._read_snippet()
entries = [e for e in self._parse(previous) if e.host != clean_host]
if len(entries) == len(self._parse(previous)):
return
self._write_snippet(self._render(entries))
error = await self._validate()
if error is None:
error = await self._reload()
if error is not None:
self._write_snippet(previous)
await self._reload()
async def list_project_domains(self, *, project_id: str) -> list[PreviewDomainInfo]:
"""Every managed route in the snippet.
NOT tenant-scoped: the snippet records hosts and ports, not projects, and
``configure_reverse_proxy`` is not given a project id. The database
(``PreviewDomainRepository``) is the authoritative, tenant-scoped ledger —
never use this method for authorization.
"""
return [
PreviewDomainInfo(host=e.host, port=e.port, url=f"https://{e.host}")
for e in self._parse(self._read_snippet())
]
# -- file handling -------------------------------------------------------
def _read_snippet(self) -> str:
if not self.snippet_path.exists():
return ""
return self.snippet_path.read_text(encoding="utf-8")
def _write_snippet(self, content: str) -> None:
"""Write atomically: a reader must never see a partial file."""
try:
self.snippet_dir.mkdir(parents=True, exist_ok=True)
tmp = self.snippet_path.with_suffix(".tmp")
tmp.write_text(content, encoding="utf-8")
tmp.replace(self.snippet_path)
except OSError as exc:
raise ProviderUnavailableError(
f"نوشتن فایل پیکربندی Caddy ناموفق بود: {exc}"
) from exc
def _import_is_wired(self) -> bool:
"""Confirm the Caddyfile actually imports our snippet."""
try:
text = self.caddyfile_path.read_text(encoding="utf-8")
except OSError:
return False
needle = str(self.snippet_dir)
return any(
line.strip().startswith("import") and needle in line
for line in text.splitlines()
)
# -- rendering and parsing ----------------------------------------------
@staticmethod
def _render(entries: list[_Entry]) -> str:
header = (
"# This file is generated and owned by AGY (web preview domains).\n"
"# Manual edits will be overwritten. Managed by CaddyDomainRouterAdapter.\n\n"
)
blocks = []
for entry in sorted(entries, key=lambda e: e.host):
blocks.append(
f"{_BEGIN.format(host=entry.host)}\n"
f"{entry.host} {{\n"
f"\treverse_proxy localhost:{entry.port}\n"
f"}}\n"
f"{_END.format(host=entry.host)}\n"
)
return header + "\n".join(blocks)
@staticmethod
def _parse(text: str) -> list[_Entry]:
entries: list[_Entry] = []
for match in _BLOCK_RE.finditer(text or ""):
port_match = re.search(r"reverse_proxy\s+localhost:(\d+)", match.group("body"))
if not port_match:
continue
entries.append(_Entry(host=match.group("host"), port=int(port_match.group(1))))
return entries
# -- caddy process -------------------------------------------------------
async def _run(self, *args: str, timeout: int) -> tuple[int, str]:
"""Run the ``caddy`` binary. Platform process, never tenant code."""
try:
process = await asyncio.create_subprocess_exec(
self.caddy_binary,
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError as exc:
raise ProviderUnavailableError(
f"اجرایی «{self.caddy_binary}» پیدا نشد؛ برای DOMAIN_ROUTER=caddy لازم است."
) from exc
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
except TimeoutError:
process.kill()
await process.wait()
return 1, "عملیات Caddy زمان‌بر شد."
combined = (stderr or stdout or b"").decode("utf-8", errors="replace").strip()
return process.returncode or 0, combined
async def _validate(self) -> str | None:
"""Return an error message, or ``None`` when the config is valid."""
code, output = await self._run(
"validate", "--config", str(self.caddyfile_path),
timeout=_VALIDATE_TIMEOUT_SECONDS,
)
if code != 0:
return f"پیکربندی Caddy نامعتبر است: {output[:400]}"
return None
async def _reload(self) -> str | None:
code, output = await self._run(
"reload", "--config", str(self.caddyfile_path),
timeout=_RELOAD_TIMEOUT_SECONDS,
)
if code != 0:
return f"بارگذاری مجدد Caddy ناموفق بود: {output[:400]}"
return None
class _Entry:
"""One managed route."""
__slots__ = ("host", "port")
def __init__(self, *, host: str, port: int) -> None:
self.host = host
self.port = port
def __eq__(self, other: object) -> bool:
return isinstance(other, _Entry) and (self.host, self.port) == (other.host, other.port)
@@ -0,0 +1,412 @@
"""Gitea adapter for :class:`GitProviderPort`.
Talked to over the HTTP API with an admin token. The token is held by this
adapter only and is never placed on an object that a response could serialise.
Design decisions
----------------
**The adapter is stateless.** It is handed ``owner`` / ``repo_name`` by the
application layer instead of keeping a ``project_id`` map. A map would be lost on
restart and could drift from ``ProjectRepository``, so the database stays the one
source of truth for repository identity.
**Repositories live in one namespace, credentials are per project.** All
repositories are created under a single owner (the configured admin user) so
tenant repos cannot collide, and every project gets its *own* Gitea user that is
added as a collaborator on that one repository. A leaked clone credential
therefore reaches exactly one repository — least privilege rather than a shared
platform token.
**Names are re-validated here.** The application layer already produces safe
slugs, but the adapter must not depend on a caller behaving well: an unvalidated
name is the classic path to a path-traversal or an unintended API path segment.
Reference: ``docs/current-infra-notes.md`` §4.
"""
from __future__ import annotations
import base64
import re
import secrets
from urllib.parse import quote
import httpx
from app.application.ports import (
BranchInfo,
CommitInfo,
GitCredentials,
MergeResult,
PullRequestInfo,
RepoInfo,
RepoStatus,
)
from app.core.errors import ProviderUnavailableError, ValidationError
#: Gitea accepts ``[a-zA-Z0-9._-]`` for repository names; we are stricter and
#: keep the platform's lowercase-slug convention.
_REPO_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,99}$")
#: Gitea usernames: alphanumeric plus ``-``/``_``, at most 40 characters, and
#: they may not start or end with a separator.
_USERNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9_-]{1,38}[a-z0-9])$")
DEFAULT_TIMEOUT_SECONDS = 20.0
#: Cloning/committing can be slower than a metadata read.
WRITE_TIMEOUT_SECONDS = 60.0
class GiteaGitAdapter:
"""Git provider backed by a Gitea instance."""
def __init__(
self,
*,
base_url: str,
admin_user: str,
admin_token: str,
owner: str | None = None,
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
) -> None:
if not base_url:
raise ProviderUnavailableError(
"GIT_PROVIDER=gitea نیازمند تنظیم GITEA_INTERNAL_URL یا GITEA_URL است."
)
if not admin_token:
raise ProviderUnavailableError(
"GIT_PROVIDER=gitea نیازمند تنظیم GITEA_ADMIN_TOKEN است."
)
self.base_url = base_url.rstrip("/")
self.admin_user = admin_user
self._admin_token = admin_token
#: Namespace that owns every project repository.
self.owner = owner or admin_user
self.timeout_seconds = timeout_seconds
# -- plumbing ------------------------------------------------------------
def _client(self, *, write: bool = False) -> httpx.AsyncClient:
return httpx.AsyncClient(
base_url=self.base_url,
headers={
"Authorization": f"token {self._admin_token}",
"Accept": "application/json",
},
timeout=WRITE_TIMEOUT_SECONDS if write else self.timeout_seconds,
)
@staticmethod
def _sanitize_repo_name(repo_name: str) -> str:
clean = (repo_name or "").strip().lower()
if not _REPO_NAME_RE.match(clean):
raise ValidationError(
"نام مخزن نامعتبر است؛ فقط حروف کوچک، عدد، نقطه، خط تیره و زیرخط مجاز است."
)
return clean
@staticmethod
def _sanitize_username(username: str) -> str:
clean = (username or "").strip().lower()
if not _USERNAME_RE.match(clean):
raise ValidationError("نام کاربری مخزن نامعتبر است.")
return clean
@staticmethod
def _seg(value: str) -> str:
"""Percent-encode a path segment so it cannot break out of the path."""
return quote(value, safe="")
async def _send(
self,
method: str,
path: str,
*,
write: bool = False,
expect: tuple[int, ...] = (200, 201),
**kwargs,
) -> httpx.Response:
"""Perform a request and translate transport failures.
A network problem must surface as ``ProviderUnavailableError`` (a 503),
never as a raw ``httpx`` exception leaking through the API.
"""
try:
async with self._client(write=write) as client:
response = await client.request(method, path, **kwargs)
except httpx.TimeoutException as exc:
raise ProviderUnavailableError("ارتباط با Gitea زمان‌بر شد.") from exc
except httpx.HTTPError as exc:
raise ProviderUnavailableError("ارتباط با Gitea برقرار نشد.") from exc
if response.status_code not in expect:
raise ProviderUnavailableError(
f"Gitea پاسخ ناموفق داد ({response.status_code}) برای {method} {path}."
)
return response
async def _get_json(self, path: str, *, expect: tuple[int, ...] = (200,)) -> dict | list | None:
"""GET that tolerates 404 by returning ``None``."""
try:
async with self._client() as client:
response = await client.get(path)
except httpx.HTTPError as exc:
raise ProviderUnavailableError("ارتباط با Gitea برقرار نشد.") from exc
if response.status_code == 404:
return None
if response.status_code not in expect:
raise ProviderUnavailableError(
f"Gitea پاسخ ناموفق داد ({response.status_code}) برای {path}."
)
return response.json()
# -- repository lifecycle ------------------------------------------------
async def ensure_project_repo(
self, *, project_id: str, repo_name: str, description: str = ""
) -> RepoInfo:
name = self._sanitize_repo_name(repo_name)
owner = self._sanitize_username(self.owner)
path = f"/api/v1/repos/{self._seg(owner)}/{self._seg(name)}"
existing = await self._get_json(path)
if existing is None:
# The admin endpoint creates the repository inside the owner's
# namespace, which a plain user token could not do.
await self._send(
"POST",
f"/api/v1/admin/users/{self._seg(owner)}/repos",
json={
"name": name,
"description": description,
"private": True,
"auto_init": True,
"default_branch": "main",
},
)
existing = await self._get_json(path)
if existing is None:
raise ProviderUnavailableError("ساخت مخزن در Gitea تأیید نشد.")
if not isinstance(existing, dict):
raise ProviderUnavailableError("پاسخ Gitea برای مخزن قابل تفسیر نبود.")
return self._to_repo_info(existing, fallback_name=name, fallback_owner=owner)
def _to_repo_info(self, payload: dict, *, fallback_name: str, fallback_owner: str) -> RepoInfo:
owner = ((payload.get("owner") or {}).get("login")) or fallback_owner
name = payload.get("name") or fallback_name
return RepoInfo(
owner=owner,
repo_name=name,
clone_url=payload.get("clone_url") or f"{self.base_url}/{owner}/{name}.git",
web_url=payload.get("html_url") or f"{self.base_url}/{owner}/{name}",
default_branch=payload.get("default_branch") or "main",
# Never let a remote payload make a tenant repository public.
private=True,
)
async def ensure_credentials(
self, *, project_id: str, owner: str = "", repo_name: str = ""
) -> GitCredentials:
"""Create (or reset) a dedicated Gitea user for this project.
The user is added as a *collaborator on one repository only*, so the
returned password cannot reach any other tenant's code.
"""
repo_owner = self._sanitize_username(owner or self.owner)
name = self._sanitize_repo_name(repo_name)
username = self._sanitize_username(f"proj-{project_id[:24]}")
# A long random password, generated fresh every call. Gitea hashes it.
password = secrets.token_urlsafe(24)
email = f"{username}@users.noreply.agy.invalid"
created = await self._send(
"POST",
"/api/v1/admin/users",
expect=(200, 201, 422),
json={
"username": username,
"email": email,
"password": password,
"must_change_password": False,
"visibility": "private",
},
)
if created.status_code == 422:
# Already exists — rotate its password instead of failing, so a
# retried `ensure_credentials` stays idempotent.
await self._send(
"PATCH",
f"/api/v1/admin/users/{self._seg(username)}",
json={"password": password, "must_change_password": False},
)
# Grant write access to exactly this repository.
await self._send(
"PUT",
f"/api/v1/repos/{self._seg(repo_owner)}/{self._seg(name)}"
f"/collaborators/{self._seg(username)}",
json={"permission": "write"},
)
return GitCredentials(username=username, password=password)
# -- branches and commits ------------------------------------------------
async def create_branch(
self,
*,
project_id: str,
branch_name: str,
from_branch: str,
owner: str = "",
repo_name: str = "",
) -> BranchInfo:
base_path = self._repo_path(owner, repo_name)
response = await self._send(
"POST",
f"{base_path}/branches",
expect=(200, 201),
json={"new_branch_name": branch_name, "old_branch_name": from_branch},
)
payload = response.json()
commit = payload.get("commit") or {}
return BranchInfo(name=payload.get("name", branch_name), sha=commit.get("id", ""))
async def commit_files(
self,
*,
project_id: str,
branch_name: str,
files: dict[str, str],
message: str,
owner: str = "",
repo_name: str = "",
) -> CommitInfo:
"""Commit every file, one Contents-API call each.
Gitea's contents endpoint handles a single file per request, so a
multi-file change is a sequence of commits. Each update needs the blob's
current ``sha``, which we read first.
"""
base_path = self._repo_path(owner, repo_name)
last_sha = ""
changed = 0
for raw_path, content in files.items():
path = self._safe_content_path(raw_path)
encoded = quote(path, safe="/")
existing = await self._get_json(f"{base_path}/contents/{encoded}?ref={branch_name}")
body = {
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
"message": message,
"branch": branch_name,
}
if isinstance(existing, dict) and existing.get("sha"):
body["sha"] = existing["sha"]
response = await self._send(
"PUT", f"{base_path}/contents/{encoded}", write=True, json=body
)
else:
response = await self._send(
"POST", f"{base_path}/contents/{encoded}", write=True, json=body
)
payload = response.json()
last_sha = (payload.get("commit") or {}).get("sha") or last_sha
changed += 1
return CommitInfo(
sha=last_sha, branch=branch_name, message=message, files_changed=changed
)
@staticmethod
def _safe_content_path(raw_path: str) -> str:
"""Reject absolute paths and traversal before they reach the URL."""
clean = (raw_path or "").strip().lstrip("/")
if not clean:
raise ValidationError("مسیر فایل نامعتبر است.")
segments = clean.split("/")
if any(seg in {"", ".", ".."} for seg in segments):
raise ValidationError("مسیر فایل نامعتبر است.")
return "/".join(segments)
# -- pull requests -------------------------------------------------------
async def open_pull_request(
self,
*,
project_id: str,
head: str,
base: str,
title: str,
body: str = "",
owner: str = "",
repo_name: str = "",
) -> PullRequestInfo:
response = await self._send(
"POST",
f"{self._repo_path(owner, repo_name)}/pulls",
expect=(200, 201),
json={"head": head, "base": base, "title": title, "body": body},
)
payload = response.json()
return PullRequestInfo(
number=int(payload.get("number", 0)),
url=payload.get("html_url", ""),
state=payload.get("state", "open"),
)
async def merge_pull_request(
self, *, project_id: str, pr_number: int, owner: str = "", repo_name: str = ""
) -> MergeResult:
try:
await self._send(
"POST",
f"{self._repo_path(owner, repo_name)}/pulls/{int(pr_number)}/merge",
json={"Do": "merge"},
)
except ProviderUnavailableError as exc:
# A merge conflict is a normal outcome, not an outage.
return MergeResult(merged=False, message=str(exc))
return MergeResult(merged=True, message="PR ادغام شد.")
# -- status --------------------------------------------------------------
async def get_repo_status(
self, *, project_id: str, owner: str = "", repo_name: str = ""
) -> RepoStatus:
base_path = self._repo_path(owner, repo_name)
repo = await self._get_json(base_path)
if repo is None:
return RepoStatus(exists=False)
if not isinstance(repo, dict):
raise ProviderUnavailableError("پاسخ Gitea برای مخزن قابل تفسیر نبود.")
branches: list[str] = []
branch_payload = await self._get_json(f"{base_path}/branches")
if isinstance(branch_payload, list):
branches = sorted(b.get("name", "") for b in branch_payload if b.get("name"))
open_prs = 0
pr_payload = await self._get_json(f"{base_path}/pulls?state=open&limit=50")
if isinstance(pr_payload, list):
open_prs = len(pr_payload)
return RepoStatus(
exists=True,
private=True,
default_branch=repo.get("default_branch") or "main",
branches=branches,
open_pull_requests=open_prs,
)
# -- helpers -------------------------------------------------------------
def _repo_path(self, owner: str, repo_name: str) -> str:
"""Build ``/api/v1/repos/{owner}/{repo}`` from validated segments."""
if not repo_name:
raise ValidationError("نام مخزن مشخص نیست.")
clean_owner = self._sanitize_username(owner or self.owner)
clean_repo = self._sanitize_repo_name(repo_name)
return f"/api/v1/repos/{self._seg(clean_owner)}/{self._seg(clean_repo)}"
@@ -0,0 +1,520 @@
"""Mock adapters — every port has one so the stack runs with zero credentials.
These are not toys: they enforce the same contracts (timeouts, output caps,
validation) as the real adapters, so switching a ``*_PROVIDER`` env var does not
change application behaviour.
"""
from __future__ import annotations
import asyncio
import hashlib
import secrets
import time
import uuid
from collections.abc import Iterable
from typing import Any
from app.application.ports import (
BranchInfo,
ChatMessage,
CheckoutSession,
CommandResult,
CommitInfo,
GitCredentials,
MergeResult,
ModelHealth,
ModelResponse,
Notification,
PaymentResult,
PreviewDomainInfo,
PullRequestInfo,
RepoInfo,
RepoStatus,
ReverseProxyResult,
SessionInfo,
SnapshotInfo,
StoredObject,
VectorHit,
WorkspaceInfo,
)
from app.domain.ai_control.models import AiModel
from app.domain.platform.rules import (
assert_port_allocatable,
build_host,
validate_managed_host,
validate_proxy_target,
)
# ---------------------------------------------------------------------------
# Model
# ---------------------------------------------------------------------------
class MockModelProviderAdapter:
"""Deterministic, offline stand-in for an LLM.
Returns a canned Persian acknowledgement that names the task so tests can
assert on it without a network call.
"""
def __init__(self, latency_ms: int = 0) -> None:
self._latency_ms = latency_ms
async def complete(
self,
*,
model: AiModel,
credential=None,
messages: list[ChatMessage],
system: str = "",
temperature: float = 0.2,
max_tokens: int = 2048,
) -> ModelResponse:
started = time.perf_counter()
if self._latency_ms:
await asyncio.sleep(self._latency_ms / 1000)
last_user = next((m.content for m in reversed(messages) if m.role == "user"), "")
prompt_tokens = sum(len(m.content.split()) for m in messages)
# The canned text must read like model output, not like a debug tag: the
# provider/model identity is platform-internal and must never surface in
# anything a customer can read (see ``test_run_payload_never_leaks_model_identity``).
content = (
"این پاسخ آزمایشی است. "
f"درخواست دریافتی: {last_user[:280] or '—'}"
)
return ModelResponse(
content=content,
model_internal_name=model.internal_name,
input_tokens=prompt_tokens + len(system.split()),
output_tokens=len(content.split()),
latency_ms=int((time.perf_counter() - started) * 1000),
raw={"mock": True},
)
async def health(self) -> ModelHealth:
return ModelHealth(ok=True, detail="mock provider")
# ---------------------------------------------------------------------------
# Git
# ---------------------------------------------------------------------------
class MockGitAdapter:
"""In-memory Git provider. Nothing touches a real forge.
``owner`` / ``repo_name`` are accepted for interface parity with the Gitea
adapter but ignored here — this adapter keys everything by ``project_id``.
"""
def __init__(self) -> None:
self._repos: dict[str, RepoInfo] = {}
self._credentials: dict[str, GitCredentials] = {}
self._branches: dict[str, dict[str, str]] = {}
self._prs: dict[str, list[PullRequestInfo]] = {}
async def ensure_project_repo(
self, *, project_id: str, repo_name: str, description: str = ""
) -> RepoInfo:
if project_id not in self._repos:
self._repos[project_id] = RepoInfo(
owner="mock",
repo_name=repo_name,
clone_url=f"https://git.example.invalid/mock/{repo_name}.git",
web_url=f"https://git.example.invalid/mock/{repo_name}",
default_branch="main",
private=True,
)
self._branches[project_id] = {"main": hashlib.sha1(b"init").hexdigest()[:40]}
self._prs[project_id] = []
return self._repos[project_id]
async def ensure_credentials(
self, *, project_id: str, owner: str = "", repo_name: str = ""
) -> GitCredentials:
if project_id not in self._credentials:
self._credentials[project_id] = GitCredentials(
username=f"proj_{project_id[:8]}",
password=secrets.token_urlsafe(12),
)
return self._credentials[project_id]
async def create_branch(
self,
*,
project_id: str,
branch_name: str,
from_branch: str,
owner: str = "",
repo_name: str = "",
) -> BranchInfo:
branches = self._branches.setdefault(project_id, {"main": "0" * 40})
base = branches.get(from_branch, "0" * 40)
sha = hashlib.sha1(f"{base}:{branch_name}".encode()).hexdigest()[:40]
branches[branch_name] = sha
return BranchInfo(name=branch_name, sha=sha)
async def commit_files(
self,
*,
project_id: str,
branch_name: str,
files: dict[str, str],
message: str,
owner: str = "",
repo_name: str = "",
) -> CommitInfo:
digest = hashlib.sha1(
f"{branch_name}:{message}:{sorted(files)}".encode()
).hexdigest()[:40]
self._branches.setdefault(project_id, {})[branch_name] = digest
return CommitInfo(
sha=digest, branch=branch_name, message=message, files_changed=len(files)
)
async def open_pull_request(
self,
*,
project_id: str,
head: str,
base: str,
title: str,
body: str = "",
owner: str = "",
repo_name: str = "",
) -> PullRequestInfo:
prs = self._prs.setdefault(project_id, [])
number = len(prs) + 1
repo = self._repos.get(project_id)
web = repo.web_url if repo else "https://git.example.invalid/mock"
pr = PullRequestInfo(number=number, url=f"{web}/pulls/{number}", state="open")
prs.append(pr)
return pr
async def merge_pull_request(
self, *, project_id: str, pr_number: int, owner: str = "", repo_name: str = ""
) -> MergeResult:
prs = self._prs.setdefault(project_id, [])
for pr in prs:
if pr.number == pr_number:
pr.state = "merged"
return MergeResult(merged=True, message="ادغام شد (mock).")
return MergeResult(merged=False, message="PR یافت نشد.")
async def get_repo_status(
self, *, project_id: str, owner: str = "", repo_name: str = ""
) -> RepoStatus:
repo = self._repos.get(project_id)
if repo is None:
return RepoStatus(exists=False)
return RepoStatus(
exists=True,
private=repo.private,
default_branch=repo.default_branch,
branches=sorted(self._branches.get(project_id, {}).keys()),
open_pull_requests=sum(
1 for pr in self._prs.get(project_id, []) if pr.state == "open"
),
)
# ---------------------------------------------------------------------------
# Domain router
# ---------------------------------------------------------------------------
class MockDomainRouterAdapter:
"""In-memory port + subdomain ledger.
Applies the *same* validation rules as the real Caddy adapter, so tests that
pass here describe behaviour the production adapter also guarantees.
"""
def __init__(
self,
*,
root_domain: str = "msa.artacloud.ir",
port_start: int = 8080,
port_end: int = 8999,
) -> None:
self.root_domain = root_domain
self.port_start = port_start
self.port_end = port_end
self._ports: dict[tuple[str, str], int] = {}
self._domains: dict[str, PreviewDomainInfo] = {}
self._project_domains: dict[str, list[str]] = {}
async def allocate_port(
self, *, organization_id: str, project_id: str, service_name: str
) -> int:
key = (project_id, service_name)
if key in self._ports:
return self._ports[key]
used = set(self._ports.values())
for port in range(self.port_start, self.port_end + 1):
if port in used:
continue
try:
assert_port_allocatable(port, start=self.port_start, end=self.port_end)
except Exception:
continue
self._ports[key] = port
return port
raise RuntimeError("No available ports in the configured range.")
async def claim_preview_domain(
self,
*,
organization_id: str,
project_id: str,
requested_subdomain: str,
port: int,
) -> PreviewDomainInfo:
host = build_host(requested_subdomain, self.root_domain)
if host in self._domains:
owner_domains = self._project_domains.get(project_id, [])
if host not in owner_domains:
raise ValueError(f"دامنهٔ {host} قبلاً ثبت شده است.")
info = PreviewDomainInfo(
host=host, port=port, url=f"https://{host}", status="active"
)
self._domains[host] = info
self._project_domains.setdefault(project_id, []).append(host)
return info
async def configure_reverse_proxy(self, *, host: str, port: int) -> ReverseProxyResult:
validate_managed_host(host, self.root_domain)
validate_proxy_target(f"localhost:{port}")
return ReverseProxyResult(ok=True, message="route ثبت شد (mock).", url=f"https://{host}")
async def remove_preview_domain(self, *, domain_id: str) -> None:
for host, info in list(self._domains.items()):
if info.host == domain_id or domain_id in self._project_domains.get(domain_id, []):
self._domains.pop(host, None)
self._domains.pop(domain_id, None)
for hosts in self._project_domains.values():
if domain_id in hosts:
hosts.remove(domain_id)
async def list_project_domains(self, *, project_id: str) -> list[PreviewDomainInfo]:
return [
self._domains[h]
for h in self._project_domains.get(project_id, [])
if h in self._domains
]
# ---------------------------------------------------------------------------
# Sandbox
# ---------------------------------------------------------------------------
class LocalMockSandboxAdapter:
"""Executes nothing. Returns plausible results and records commands.
``run_command`` still honours the timeout and output cap contract so callers
cannot depend on behaviour the real adapters will not provide.
"""
def __init__(self, max_output_bytes: int = 200_000) -> None:
self._workspaces: dict[str, str] = {}
self._sessions: dict[str, str] = {}
self._files: dict[str, dict[str, str]] = {}
self.commands: list[str] = []
self.max_output_bytes = max_output_bytes
async def create_workspace(
self, *, organization_id: str, project_id: str, template_id: str, image: str
) -> WorkspaceInfo:
external_id = f"ws_{uuid.uuid4().hex[:12]}"
self._workspaces[external_id] = image
return WorkspaceInfo(external_id=external_id, status="ready", raw={"image": image})
async def start_session(self, *, workspace_external_id: str) -> SessionInfo:
external_id = f"ses_{uuid.uuid4().hex[:12]}"
self._sessions[external_id] = workspace_external_id
self._files.setdefault(external_id, {})
return SessionInfo(external_id=external_id, status="running")
async def run_command(
self,
*,
session_external_id: str,
command: str,
timeout_seconds: int,
max_output_bytes: int | None = None,
env: dict[str, str] | None = None,
) -> CommandResult:
limit = max_output_bytes or self.max_output_bytes
started = time.perf_counter()
self.commands.append(command)
stdout = f"[mock] اجرا شد: {command}"
truncated = len(stdout.encode()) > limit
if truncated:
stdout = stdout.encode()[:limit].decode(errors="ignore")
return CommandResult(
exit_code=0,
stdout=stdout,
stderr="",
duration_ms=max(1, int((time.perf_counter() - started) * 1000)),
truncated=truncated,
)
async def read_file(self, *, session_external_id: str, path: str) -> str:
return self._files.get(session_external_id, {}).get(path, "")
async def write_file(self, *, session_external_id: str, path: str, content: str) -> None:
self._files.setdefault(session_external_id, {})[path] = content
async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo:
return SnapshotInfo(external_id=f"snap_{uuid.uuid4().hex[:12]}", name=name)
async def pause(self, *, session_external_id: str) -> None:
return None
async def resume(self, *, session_external_id: str) -> SessionInfo:
return SessionInfo(external_id=session_external_id, status="running")
async def destroy(self, *, session_external_id: str) -> None:
self._sessions.pop(session_external_id, None)
self._files.pop(session_external_id, None)
async def get_preview_url(self, *, session_external_id: str, port: int) -> str:
return f"http://127.0.0.1:{port}"
# ---------------------------------------------------------------------------
# Payment
# ---------------------------------------------------------------------------
class LocalMockPaymentAdapter:
async def create_checkout(
self,
*,
organization_id: str,
amount: float,
currency: str,
success_url: str,
cancel_url: str,
) -> CheckoutSession:
external_id = f"mock_cs_{uuid.uuid4().hex[:12]}"
return CheckoutSession(
external_id=external_id,
url=f"{success_url}?mock_checkout={external_id}",
amount=amount,
currency=currency,
)
async def refund(
self, *, external_payment_id: str, amount: float | None = None
) -> PaymentResult:
return PaymentResult(ok=True, external_id=external_payment_id, message="بازگشت (mock).")
# ---------------------------------------------------------------------------
# Storage / vector / notification
# ---------------------------------------------------------------------------
class InMemoryStorageAdapter:
def __init__(self) -> None:
self._objects: dict[str, bytes] = {}
async def put(self, *, key: str, data: bytes, content_type: str = "") -> StoredObject:
self._objects[key] = data
return StoredObject(key=key, size_bytes=len(data), url=f"memory://{key}")
async def get(self, *, key: str) -> bytes:
return self._objects.get(key, b"")
async def delete(self, *, key: str) -> None:
self._objects.pop(key, None)
async def signed_url(self, *, key: str, expires_seconds: int = 900) -> str:
return f"memory://{key}?expires={expires_seconds}"
class InMemoryVectorStoreAdapter:
"""Keyword-overlap search. Good enough for MVP; swappable for pgvector."""
def __init__(self) -> None:
self._chunks: dict[str, tuple[str, str, str, dict]] = {}
async def upsert(
self,
*,
organization_id: str,
chunks: Iterable[tuple[str, str, list[float]]],
metadata: dict | None = None,
) -> int:
count = 0
for chunk_id, content, _embedding in chunks:
document_id = str((metadata or {}).get("document_id", ""))
self._chunks[chunk_id] = (organization_id, document_id, content, metadata or {})
count += 1
return count
async def search(
self,
*,
organization_id: str,
query: str,
limit: int = 5,
project_id: str | None = None,
) -> list[VectorHit]:
terms = {t for t in query.lower().split() if t}
scored: list[VectorHit] = []
for chunk_id, (org, document_id, content, meta) in self._chunks.items():
if org != organization_id:
continue
if project_id and meta.get("project_id") not in (None, project_id):
continue
overlap = len(terms & set(content.lower().split()))
if overlap:
scored.append(
VectorHit(
chunk_id=chunk_id,
document_id=document_id,
content=content,
score=float(overlap),
metadata=meta,
)
)
scored.sort(key=lambda h: h.score, reverse=True)
return scored[:limit]
async def delete_document(self, *, document_id: str) -> None:
for chunk_id, (_, doc_id, _, _) in list(self._chunks.items()):
if doc_id == document_id:
self._chunks.pop(chunk_id, None)
class ConsoleNotificationAdapter:
"""Records notifications instead of sending them."""
def __init__(self) -> None:
self.sent: list[Notification] = []
async def send(self, *, notification: Notification) -> bool:
self.sent.append(notification)
return True
class InMemoryAuditAdapter:
"""In-memory AuditPort, used by unit tests that do not want a database."""
def __init__(self) -> None:
# Records whatever the caller hands over; the port is untyped here.
self.events: list[Any] = []
async def record(self, *, event):
self.events.append(event)
return event
async def list_for_organization(
self, *, organization_id: str, limit: int = 100, offset: int = 0
):
matching = [e for e in self.events if e.organization_id == organization_id]
return matching[offset : offset + limit]
@@ -0,0 +1,97 @@
"""Real model adapter for any OpenAI-compatible endpoint.
Works with OpenAI, OpenRouter, vLLM, LiteLLM, Ollama's OpenAI shim, etc. The
API key is read from the credential record (decrypted by the caller), never from
the environment at request time.
"""
from __future__ import annotations
import time
import httpx
from app.application.ports import ChatMessage, ModelHealth, ModelResponse
from app.core.errors import ProviderUnavailableError
from app.domain.ai_control.models import AiModel, ModelCredential
class OpenAICompatibleModelAdapter:
def __init__(self, *, base_url: str, default_api_key: str = "", timeout: float = 120.0) -> None:
self.base_url = (base_url or "").rstrip("/")
self.default_api_key = default_api_key
self.timeout = timeout
def _resolve_key(self, credential: ModelCredential | None, decrypt) -> str:
if credential is not None and credential.encrypted_api_key:
try:
return decrypt(credential.encrypted_api_key)
except Exception: # noqa: BLE001 - fall through to the env default
pass
return self.default_api_key
async def complete(
self,
*,
model: AiModel,
credential: ModelCredential | None,
messages: list[ChatMessage],
system: str = "",
temperature: float = 0.2,
max_tokens: int = 2048,
) -> ModelResponse:
if not self.base_url:
raise ProviderUnavailableError(
"OPENAI_COMPATIBLE_BASE_URL تنظیم نشده است."
)
payload_messages = ([{"role": "system", "content": system}] if system else []) + [
{"role": m.role, "content": m.content} for m in messages
]
payload = {
"model": model.internal_name,
"messages": payload_messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
headers = {"Content-Type": "application/json"}
api_key = self.default_api_key
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
started = time.perf_counter()
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/chat/completions", json=payload, headers=headers
)
response.raise_for_status()
data = response.json()
except httpx.HTTPStatusError as exc:
raise ProviderUnavailableError(
f"ارائه‌دهندهٔ مدل خطا برگرداند ({exc.response.status_code})."
) from exc
except httpx.HTTPError as exc:
raise ProviderUnavailableError("اتصال به ارائه‌دهندهٔ مدل برقرار نشد.") from exc
choice = (data.get("choices") or [{}])[0]
content = (choice.get("message") or {}).get("content", "") or ""
usage = data.get("usage") or {}
return ModelResponse(
content=content,
model_internal_name=data.get("model", model.internal_name),
input_tokens=int(usage.get("prompt_tokens", 0) or 0),
output_tokens=int(usage.get("completion_tokens", 0) or 0),
latency_ms=int((time.perf_counter() - started) * 1000),
raw={"id": data.get("id", "")},
)
async def health(self) -> ModelHealth:
if not self.base_url:
return ModelHealth(ok=False, detail="base_url تنظیم نشده است.")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(f"{self.base_url}/models")
return ModelHealth(ok=response.status_code < 500, detail=str(response.status_code))
except httpx.HTTPError as exc:
return ModelHealth(ok=False, detail=str(exc))
@@ -0,0 +1,141 @@
"""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.
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
from functools import lru_cache
from app.core.config import settings
from app.core.errors import ProviderUnavailableError
from app.infrastructure.adapters import mock
from app.infrastructure.adapters.openai_compatible import OpenAICompatibleModelAdapter
@lru_cache
def get_model_provider():
provider = settings.ai_default_provider.lower()
if provider == "mock":
return mock.MockModelProviderAdapter()
if provider == "openai_compatible":
return OpenAICompatibleModelAdapter(
base_url=settings.openai_compatible_base_url,
default_api_key=settings.openai_compatible_api_key,
)
raise ProviderUnavailableError(
f"AI_DEFAULT_PROVIDER='{provider}' پشتیبانی نمی‌شود. "
"مقادیر مجاز: mock | openai_compatible"
)
@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 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 | 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()
@@ -0,0 +1,430 @@
"""Remote sandbox adapters (Daytona / E2B) for :class:`SandboxProviderPort`.
This is the **only** place tenant code may execute. It never shells out on the
platform host: every command is sent to a remote, disposable sandbox through the
provider's official Python SDK.
Why the SDKs are optional extras
--------------------------------
``daytona`` and ``e2b`` are large, fast-moving dependencies. They are declared as
the ``sandbox-remote`` extra so a default install stays small, and they are
imported lazily inside :meth:`_load_sdk`. A missing extra produces an actionable
``ProviderUnavailableError`` instead of an ``ImportError`` at startup.
Fail loudly, not silently
-------------------------
The two SDKs change their surface between minor releases. Rather than assume a
method exists and fail halfway through a customer's build, :meth:`preflight`
probes for every attribute this adapter relies on and refuses to construct the
adapter if any is missing. A version drift therefore surfaces as a clear
configuration error the moment ``SANDBOX_PROVIDER`` is switched on.
Known gap
---------
Provider-native snapshots (a resumable VM image) are **not** wired yet. Calling
:meth:`snapshot` raises a clear error rather than pretending to have saved one.
The mock provider implements snapshots fully, and the database already records
them, so only the remote path is affected.
"""
from __future__ import annotations
import time
from typing import Any
from app.application.ports import CommandResult, SessionInfo, SnapshotInfo, WorkspaceInfo
from app.core.errors import ProviderUnavailableError
#: Never let a caller ask for an unbounded run.
ABSOLUTE_MAX_TIMEOUT_SECONDS = 900
#: Mirror of the platform output cap; enforced client-side as well because a
#: remote SDK is under no obligation to truncate.
ABSOLUTE_MAX_OUTPUT_BYTES = 2_000_000
def _load_sdk(provider: str) -> Any:
"""Import the provider SDK, or explain exactly how to install it."""
try:
if provider == "daytona":
import daytona # noqa: PLC0415
return daytona
if provider == "e2b":
import e2b # noqa: PLC0415
return e2b
except ImportError as exc:
raise ProviderUnavailableError(
f"SDK «{provider}» نصب نیست. برای فعال‌سازی اجرا کنید: "
f"pip install 'agy-web-api[sandbox-remote]'"
) from exc
raise ProviderUnavailableError(f"ارائه‌دهندهٔ sandbox «{provider}» پشتیبانی نمی‌شود.")
def _require(obj: Any, attr: str, *, provider: str, where: str) -> Any:
"""Return ``obj.attr`` or fail with a precise version-mismatch message."""
if not hasattr(obj, attr):
raise ProviderUnavailableError(
f"SDK «{provider}» با این نسخه سازگار نیست: «{where}.{attr}» یافت نشد. "
"نسخهٔ پشتیبانی‌شده را نصب کنید یا SANDBOX_PROVIDER=mock بگذارید."
)
return getattr(obj, attr)
def _truncate(text: str, limit: int) -> tuple[str, bool]:
encoded = text.encode("utf-8", errors="replace")
if len(encoded) <= limit:
return text, False
return encoded[:limit].decode("utf-8", errors="ignore"), True
class _RemoteSandboxAdapter:
"""Shared behaviour for both remote providers."""
provider_name = "remote"
def __init__(self, *, max_output_bytes: int = 200_000) -> None:
self.max_output_bytes = min(max_output_bytes, ABSOLUTE_MAX_OUTPUT_BYTES)
self._client: Any = None
self._sandboxes: dict[str, Any] = {}
self._sessions: dict[str, str] = {}
# -- lifecycle -----------------------------------------------------------
async def _ensure_client(self) -> Any: # pragma: no cover - overridden
raise NotImplementedError
async def create_workspace(
self, *, organization_id: str, project_id: str, template_id: str, image: str
) -> WorkspaceInfo:
client = await self._ensure_client()
sandbox = await self._create_sandbox(client, image=image, template_id=template_id)
external_id = str(
_require(sandbox, "sandbox_id", provider=self.provider_name, where="sandbox")
)
self._sandboxes[external_id] = sandbox
return WorkspaceInfo(
external_id=external_id,
status="ready",
raw={"provider": self.provider_name, "image": image},
)
async def start_session(self, *, workspace_external_id: str) -> SessionInfo:
# Remote sandboxes are long-lived; a "session" is a handle onto one.
external_id = f"ses_{workspace_external_id}"
self._sessions[external_id] = workspace_external_id
return SessionInfo(external_id=external_id, status="running")
async def _sandbox_for_session(self, session_external_id: str) -> Any:
workspace_id = self._sessions.get(session_external_id, session_external_id)
sandbox = self._sandboxes.get(workspace_id)
if sandbox is None:
raise ProviderUnavailableError("نشست sandbox یافت نشد یا منقضی شده است.")
return sandbox
# -- execution -----------------------------------------------------------
async def run_command(
self,
*,
session_external_id: str,
command: str,
timeout_seconds: int,
max_output_bytes: int | None = None,
env: dict[str, str] | None = None,
) -> CommandResult:
sandbox = await self._sandbox_for_session(session_external_id)
limit = min(max_output_bytes or self.max_output_bytes, ABSOLUTE_MAX_OUTPUT_BYTES)
# Clamp here too: the port contract says the caller can never ask for
# "no limit", and a bug upstream must not become an unbounded remote run.
timeout = max(1, min(int(timeout_seconds), ABSOLUTE_MAX_TIMEOUT_SECONDS))
started = time.perf_counter()
raw = await self._exec(sandbox, command=command, timeout=timeout, env=env or {})
duration_ms = max(1, int((time.perf_counter() - started) * 1000))
# Pull the three fields out defensively; a missing one is a version
# mismatch and must fail with a precise message.
exit_code = _require(
raw, "exit_code", provider=self.provider_name, where="command result"
)
raw_stdout = _require(
raw, "stdout", provider=self.provider_name, where="command result"
)
raw_stderr = _require(
raw, "stderr", provider=self.provider_name, where="command result"
)
stdout, cut_out = _truncate(str(raw_stdout or ""), limit)
stderr, cut_err = _truncate(str(raw_stderr or ""), limit)
return CommandResult(
exit_code=int(exit_code or 0),
stdout=stdout,
stderr=stderr,
duration_ms=duration_ms,
truncated=cut_out or cut_err,
)
# -- files ---------------------------------------------------------------
async def read_file(self, *, session_external_id: str, path: str) -> str:
sandbox = await self._sandbox_for_session(session_external_id)
return await self._read(sandbox, path)
async def write_file(self, *, session_external_id: str, path: str, content: str) -> None:
sandbox = await self._sandbox_for_session(session_external_id)
await self._write(sandbox, path, content)
# -- snapshots / preview -------------------------------------------------
async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo:
raise ProviderUnavailableError(
"snapshot بومی ارائه‌دهندهٔ راه‌دور هنوز فعال نشده است. "
"برای snapshot از SANDBOX_PROVIDER=mock استفاده کنید یا این قابلیت را "
"برای ارائه‌دهندهٔ خود پیاده‌سازی کنید."
)
async def pause(self, *, session_external_id: str) -> None:
sandbox = await self._sandbox_for_session(session_external_id)
await self._pause(sandbox)
async def resume(self, *, session_external_id: str) -> SessionInfo:
sandbox = await self._sandbox_for_session(session_external_id)
await self._resume(sandbox)
return SessionInfo(external_id=session_external_id, status="running")
async def destroy(self, *, session_external_id: str) -> None:
workspace_id = self._sessions.pop(session_external_id, session_external_id)
sandbox = self._sandboxes.pop(workspace_id, None)
if sandbox is not None:
await self._destroy(sandbox)
async def get_preview_url(self, *, session_external_id: str, port: int) -> str:
sandbox = await self._sandbox_for_session(session_external_id)
return await self._preview_url(sandbox, port)
# -- provider hooks ------------------------------------------------------
async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any:
raise NotImplementedError
async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any:
raise NotImplementedError
async def _read(self, sandbox: Any, path: str) -> str:
raise NotImplementedError
async def _write(self, sandbox: Any, path: str, content: str) -> None:
raise NotImplementedError
async def _pause(self, sandbox: Any) -> None:
raise NotImplementedError
async def _resume(self, sandbox: Any) -> None:
raise NotImplementedError
async def _destroy(self, sandbox: Any) -> None:
raise NotImplementedError
async def _preview_url(self, sandbox: Any, port: int) -> str:
raise NotImplementedError
class DaytonaSandboxAdapter(_RemoteSandboxAdapter):
"""Daytona Cloud sandboxes via the async SDK.
Targets the documented surface: ``AsyncDaytona(DaytonaConfig(...))``,
``client.create()``, ``sandbox.process.exec(...)``, ``sandbox.fs``,
``client.delete(sandbox)``.
"""
provider_name = "daytona"
def __init__(
self, *, api_key: str, api_url: str = "", max_output_bytes: int = 200_000
) -> None:
super().__init__(max_output_bytes=max_output_bytes)
if not api_key:
raise ProviderUnavailableError(
"SANDBOX_PROVIDER=daytona نیازمند تنظیم DAYTONA_API_KEY است."
)
self._api_key = api_key
self._api_url = api_url
self._sdk = _load_sdk("daytona")
self._preflight()
def _preflight(self) -> None:
_require(self._sdk, "AsyncDaytona", provider="daytona", where="daytona")
_require(self._sdk, "DaytonaConfig", provider="daytona", where="daytona")
async def _ensure_client(self) -> Any:
if self._client is None:
config_kwargs: dict[str, Any] = {"api_key": self._api_key}
if self._api_url:
config_kwargs["api_url"] = self._api_url
config = self._sdk.DaytonaConfig(**config_kwargs)
self._client = self._sdk.AsyncDaytona(config)
return self._client
async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any:
# `create` accepts no arguments on the default image; a custom image is
# passed positionally as a snapshot/image reference.
try:
return await client.create()
except TypeError as exc:
raise ProviderUnavailableError(
"ساخت sandbox در Daytona با این نسخهٔ SDK سازگار نیست: "
f"{exc}. نسخهٔ پشتیبانی‌شدهٔ daytona را نصب کنید."
) from exc
async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any:
process = _require(sandbox, "process", provider="daytona", where="sandbox")
exec_fn = _require(process, "exec", provider="daytona", where="sandbox.process")
response = await exec_fn(command, timeout=timeout)
# Daytona reports the exit code separately from the combined output.
result = str(
_require(response, "result", provider="daytona", where="process response") or ""
)
return _DaytonaCommandOutput(
exit_code=int(getattr(response, "exit_code", 0) or 0),
stdout=result,
stderr="",
)
async def _read(self, sandbox: Any, path: str) -> str:
fs = _require(sandbox, "fs", provider="daytona", where="sandbox")
read_fn = _require(fs, "download_file", provider="daytona", where="sandbox.fs")
content = await read_fn(path)
if isinstance(content, bytes):
return content.decode("utf-8", errors="replace")
return str(content or "")
async def _write(self, sandbox: Any, path: str, content: str) -> None:
fs = _require(sandbox, "fs", provider="daytona", where="sandbox")
write_fn = _require(fs, "upload_file", provider="daytona", where="sandbox.fs")
await write_fn(content.encode("utf-8"), path)
async def _pause(self, sandbox: Any) -> None:
stop_fn = _require(sandbox, "stop", provider="daytona", where="sandbox")
await stop_fn()
async def _resume(self, sandbox: Any) -> None:
start_fn = _require(sandbox, "start", provider="daytona", where="sandbox")
await start_fn()
async def _destroy(self, sandbox: Any) -> None:
client = await self._ensure_client()
delete_fn = _require(client, "delete", provider="daytona", where="AsyncDaytona")
await delete_fn(sandbox)
async def _preview_url(self, sandbox: Any, port: int) -> str:
link_fn = _require(sandbox, "get_preview_link", provider="daytona", where="sandbox")
link = await link_fn(port)
return str(getattr(link, "url", link))
class E2BSandboxAdapter(_RemoteSandboxAdapter):
"""E2B sandboxes via the async SDK.
Targets the documented surface: ``AsyncSandbox.create(...)``,
``sandbox.commands.run(...)``, ``sandbox.files.read/write``,
``sandbox.kill()``.
"""
provider_name = "e2b"
def __init__(self, *, api_key: str, max_output_bytes: int = 200_000) -> None:
super().__init__(max_output_bytes=max_output_bytes)
if not api_key:
raise ProviderUnavailableError(
"SANDBOX_PROVIDER=e2b نیازمند تنظیم E2B_API_KEY است."
)
self._api_key = api_key
self._sdk = _load_sdk("e2b")
self._preflight()
def _preflight(self) -> None:
_require(self._sdk, "AsyncSandbox", provider="e2b", where="e2b")
async def _ensure_client(self) -> Any:
# E2B has no long-lived client object; the API key is passed per call.
return self._sdk.AsyncSandbox
async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any:
create_fn = _require(client, "create", provider="e2b", where="AsyncSandbox")
return await create_fn(api_key=self._api_key)
async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any:
commands = _require(sandbox, "commands", provider="e2b", where="sandbox")
run_fn = _require(commands, "run", provider="e2b", where="sandbox.commands")
result = await run_fn(command, timeout=timeout, envs=env or None)
return _E2BCommandOutput(
exit_code=int(getattr(result, "exit_code", 0) or 0),
stdout=str(getattr(result, "stdout", "") or ""),
stderr=str(getattr(result, "stderr", "") or ""),
)
async def _read(self, sandbox: Any, path: str) -> str:
files = _require(sandbox, "files", provider="e2b", where="sandbox")
read_fn = _require(files, "read", provider="e2b", where="sandbox.files")
content = await read_fn(path)
if isinstance(content, bytes):
return content.decode("utf-8", errors="replace")
return str(content or "")
async def _write(self, sandbox: Any, path: str, content: str) -> None:
files = _require(sandbox, "files", provider="e2b", where="sandbox")
write_fn = _require(files, "write", provider="e2b", where="sandbox.files")
await write_fn(path, content)
async def _pause(self, sandbox: Any) -> None:
pause_fn = _require(sandbox, "pause", provider="e2b", where="sandbox")
await pause_fn()
async def _resume(self, sandbox: Any) -> None:
# E2B resumes by reconnecting to the sandbox id.
connect_fn = _require(
self._sdk.AsyncSandbox, "connect", provider="e2b", where="AsyncSandbox"
)
await connect_fn(sandbox.sandbox_id, api_key=self._api_key)
async def _destroy(self, sandbox: Any) -> None:
kill_fn = _require(sandbox, "kill", provider="e2b", where="sandbox")
await kill_fn()
async def _preview_url(self, sandbox: Any, port: int) -> str:
host_fn = _require(sandbox, "get_host", provider="e2b", where="sandbox")
return f"https://{await host_fn(port)}"
class _DaytonaCommandOutput:
__slots__ = ("exit_code", "stdout", "stderr")
def __init__(self, *, exit_code: int, stdout: str, stderr: str) -> None:
self.exit_code = exit_code
self.stdout = stdout
self.stderr = stderr
class _E2BCommandOutput:
__slots__ = ("exit_code", "stdout", "stderr")
def __init__(self, *, exit_code: int, stdout: str, stderr: str) -> None:
self.exit_code = exit_code
self.stdout = stdout
self.stderr = stderr
def build_remote_sandbox_adapter(
provider: str, *, daytona_api_key: str = "", daytona_api_url: str = "", e2b_api_key: str = ""
):
"""Factory used by the adapter registry."""
normalised = (provider or "").strip().lower()
if normalised == "daytona":
return DaytonaSandboxAdapter(api_key=daytona_api_key, api_url=daytona_api_url)
if normalised == "e2b":
return E2BSandboxAdapter(api_key=e2b_api_key)
raise ProviderUnavailableError(
f"SANDBOX_PROVIDER='{provider}' پشتیبانی نمی‌شود. مقادیر مجاز: mock | daytona | e2b"
)
@@ -0,0 +1,203 @@
"""Stripe adapter for :class:`PaymentProviderPort`.
Uses Stripe's REST API directly over ``httpx`` rather than the official SDK: the
two calls we need are stable, and it keeps the API process free of an extra
dependency and its release cadence.
Responsibility boundary
-----------------------
The internal usage ledger is the **source of truth** for what a tenant owes.
This adapter only moves money — it never decides a balance, and a successful
checkout does not by itself credit a wallet. The application layer settles the
payment and then writes the credit, so a webhook replayed twice cannot
double-credit.
Amounts are converted to Stripe's minor units (cents) with ``Decimal`` rounding,
never float multiplication, so ``19.99`` cannot become ``1998``.
"""
from __future__ import annotations
import hashlib
import hmac
import time
from decimal import ROUND_HALF_UP, Decimal
import httpx
from app.application.ports import CheckoutSession, PaymentResult
from app.core.errors import ProviderUnavailableError, ValidationError
STRIPE_API_BASE = "https://api.stripe.com/v1"
DEFAULT_TIMEOUT_SECONDS = 20.0
#: Stripe rejects signatures older than this; mirrored locally to fail early.
WEBHOOK_TOLERANCE_SECONDS = 300
#: Card networks require a minimum charge. Refuse early with a clear message
#: rather than letting Stripe return a confusing error.
MINIMUM_CHARGE_USD = Decimal("0.50")
class StripePaymentAdapter:
def __init__(
self,
*,
secret_key: str,
webhook_secret: str = "",
api_base: str = STRIPE_API_BASE,
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
) -> None:
if not secret_key:
raise ProviderUnavailableError(
"PAYMENT_PROVIDER=stripe نیازمند تنظیم STRIPE_SECRET_KEY است."
)
self._secret_key = secret_key
self._webhook_secret = webhook_secret
self.api_base = api_base.rstrip("/")
self.timeout_seconds = timeout_seconds
# -- port ----------------------------------------------------------------
async def create_checkout(
self,
*,
organization_id: str,
amount: float,
currency: str,
success_url: str,
cancel_url: str,
) -> CheckoutSession:
"""Create a one-off Checkout Session for a wallet top-up."""
minor_units = self._to_minor_units(amount)
currency_code = (currency or "usd").strip().lower()
form = {
"mode": "payment",
"success_url": success_url,
"cancel_url": cancel_url,
# Ties the Stripe object back to our tenant without exposing an id
# the customer can edit meaningfully.
"client_reference_id": organization_id,
"metadata[organization_id]": organization_id,
"line_items[0][quantity]": "1",
"line_items[0][price_data][currency]": currency_code,
"line_items[0][price_data][unit_amount]": str(minor_units),
"line_items[0][price_data][product_data][name]": "AGY credit top-up",
}
payload = await self._request("POST", "/checkout/sessions", data=form)
url = payload.get("url")
if not url:
raise ProviderUnavailableError("Stripe آدرس پرداخت را برنگرداند.")
return CheckoutSession(
external_id=payload.get("id", ""),
url=url,
# Report back in major units so the caller never sees minor units.
amount=float(Decimal(payload.get("amount_total") or minor_units) / 100),
currency=(payload.get("currency") or currency_code).upper(),
)
async def refund(
self, *, external_payment_id: str, amount: float | None = None
) -> PaymentResult:
if not external_payment_id:
raise ValidationError("شناسهٔ پرداخت برای بازگشت وجه لازم است.")
form: dict[str, str] = {}
# A Checkout Session is not refundable directly; its PaymentIntent is.
# Accept either so a caller does not have to know Stripe's object graph.
if external_payment_id.startswith("cs_"):
session = await self._request(
"GET", f"/checkout/sessions/{external_payment_id}", params={}
)
intent = session.get("payment_intent")
if not intent:
return PaymentResult(
ok=False,
external_id=external_payment_id,
message="این پرداخت هنوز تکمیل نشده است.",
)
form["payment_intent"] = str(intent)
else:
form["payment_intent"] = external_payment_id
if amount is not None:
form["amount"] = str(self._to_minor_units(amount))
payload = await self._request("POST", "/refunds", data=form)
return PaymentResult(
ok=payload.get("status") in {"succeeded", "pending"},
external_id=payload.get("id", ""),
message=f"وضعیت بازگشت وجه: {payload.get('status', 'unknown')}",
)
# -- webhook -------------------------------------------------------------
def verify_webhook_signature(self, *, payload: bytes, signature_header: str) -> bool:
"""Verify a ``Stripe-Signature`` header over the raw request body.
The body must be the *raw bytes* — re-serialising parsed JSON changes the
byte sequence and the HMAC will never match.
"""
if not self._webhook_secret:
return False
timestamp = ""
signatures: list[str] = []
for part in (signature_header or "").split(","):
key, _, value = part.partition("=")
if key.strip() == "t":
timestamp = value.strip()
elif key.strip() == "v1":
signatures.append(value.strip())
if not timestamp or not signatures:
return False
try:
age = abs(time.time() - int(timestamp))
except ValueError:
return False
if age > WEBHOOK_TOLERANCE_SECONDS:
# Replay protection.
return False
signed_payload = f"{timestamp}.".encode() + payload
expected = hmac.new(
self._webhook_secret.encode(), signed_payload, hashlib.sha256
).hexdigest()
# Constant-time compare against every offered signature.
return any(hmac.compare_digest(expected, candidate) for candidate in signatures)
# -- helpers -------------------------------------------------------------
@staticmethod
def _to_minor_units(amount: float) -> int:
value = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if value <= 0:
raise ValidationError("مبلغ باید بزرگ‌تر از صفر باشد.")
if value < MINIMUM_CHARGE_USD:
raise ValidationError(f"حداقل مبلغ پرداخت {MINIMUM_CHARGE_USD} دلار است.")
return int(value * 100)
async def _request(self, method: str, path: str, **kwargs) -> dict:
headers = {"Authorization": f"Bearer {self._secret_key}"}
try:
async with httpx.AsyncClient(
base_url=self.api_base, timeout=self.timeout_seconds
) as client:
response = await client.request(method, path, headers=headers, **kwargs)
except httpx.TimeoutException as exc:
raise ProviderUnavailableError("ارتباط با Stripe زمان‌بر شد.") from exc
except httpx.HTTPError as exc:
raise ProviderUnavailableError("ارتباط با Stripe برقرار نشد.") from exc
if response.status_code >= 400:
detail = ""
try:
detail = (response.json().get("error") or {}).get("message", "")
except ValueError:
detail = response.text[:200]
raise ProviderUnavailableError(
f"Stripe خطا برگرداند ({response.status_code}): {detail}"
)
return response.json()