نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
"""Sandbox Gateway endpoints.
|
||||
|
||||
Note what is **absent**: there is no route that accepts a shell command and runs
|
||||
it on the host. Every execution goes to ``SandboxProviderPort``, is bounded by a
|
||||
policy timeout and output cap, and is metered into the usage ledger.
|
||||
|
||||
Running a command requires ``sandbox.use``; tearing a workspace down requires
|
||||
``projects.manage`` because it destroys work.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.sandbox_service import SandboxService
|
||||
from app.core.database import get_session
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.identity.models import CAN_MANAGE_PROJECTS, CAN_USE_SANDBOX
|
||||
from app.domain.sandbox.models import NetworkPolicy
|
||||
from app.interfaces.http.deps import (
|
||||
OrgContext,
|
||||
client_meta,
|
||||
get_org_context,
|
||||
get_sandbox_service,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/organizations/{org_id}/sandbox", tags=["sandbox"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SandboxTemplateOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
provider: str
|
||||
image: str
|
||||
description: str
|
||||
default_cpu: float
|
||||
default_memory_mb: int
|
||||
default_disk_mb: int
|
||||
|
||||
|
||||
class WorkspaceOut(BaseModel):
|
||||
id: str
|
||||
project_id: str
|
||||
template_id: str
|
||||
provider: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CreateWorkspaceRequest(BaseModel):
|
||||
project_id: str
|
||||
template_id: str
|
||||
|
||||
|
||||
class SessionOut(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
status: str
|
||||
started_at: datetime
|
||||
last_active_at: datetime
|
||||
|
||||
|
||||
class StartSessionRequest(BaseModel):
|
||||
workspace_id: str
|
||||
|
||||
|
||||
class CommandRunOut(BaseModel):
|
||||
id: str
|
||||
command: str
|
||||
exit_code: int | None
|
||||
stdout: str
|
||||
stderr: str
|
||||
duration_ms: int
|
||||
truncated: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RunCommandRequest(BaseModel):
|
||||
command: str = Field(min_length=1, max_length=4_000)
|
||||
|
||||
|
||||
class FileReadOut(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
|
||||
class ReadFileRequest(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1_000)
|
||||
|
||||
|
||||
class WriteFileRequest(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1_000)
|
||||
content: str = Field(max_length=1_000_000)
|
||||
|
||||
|
||||
class SnapshotOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CreateSnapshotRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
|
||||
|
||||
class PreviewUrlOut(BaseModel):
|
||||
port: int
|
||||
url: str
|
||||
|
||||
|
||||
class ResourceLimitOut(BaseModel):
|
||||
cpu: float
|
||||
memory_mb: int
|
||||
disk_mb: int
|
||||
timeout_seconds: int
|
||||
max_output_bytes: int
|
||||
network_policy: str
|
||||
|
||||
|
||||
class SetLimitsRequest(BaseModel):
|
||||
scope: str = "organization"
|
||||
scope_id: str | None = None
|
||||
cpu: float = Field(default=1.0, gt=0, le=16)
|
||||
memory_mb: int = Field(default=1024, ge=128, le=65_536)
|
||||
disk_mb: int = Field(default=2048, ge=256, le=1_048_576)
|
||||
timeout_seconds: int = Field(default=120, ge=1, le=900)
|
||||
max_output_bytes: int = Field(default=200_000, ge=1024, le=2_000_000)
|
||||
network_policy: NetworkPolicy = NetworkPolicy.DENY_ALL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Templates & limits
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/templates", response_model=list[SandboxTemplateOut])
|
||||
async def list_templates(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> list[SandboxTemplateOut]:
|
||||
templates = await service.list_templates()
|
||||
return [
|
||||
SandboxTemplateOut(
|
||||
id=t.id, name=t.name, provider=str(t.provider), image=t.image,
|
||||
description=t.description, default_cpu=t.default_cpu,
|
||||
default_memory_mb=t.default_memory_mb, default_disk_mb=t.default_disk_mb,
|
||||
)
|
||||
for t in templates
|
||||
]
|
||||
|
||||
|
||||
@router.get("/limits", response_model=ResourceLimitOut)
|
||||
async def get_limits(
|
||||
project_id: str | None = Query(default=None),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> ResourceLimitOut:
|
||||
limit = await service.effective_limits(
|
||||
organization_id=context.org_id, project_id=project_id
|
||||
)
|
||||
return ResourceLimitOut(
|
||||
cpu=limit.cpu, memory_mb=limit.memory_mb, disk_mb=limit.disk_mb,
|
||||
timeout_seconds=limit.timeout_seconds, max_output_bytes=limit.max_output_bytes,
|
||||
network_policy=str(limit.network_policy),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/limits", response_model=ResourceLimitOut)
|
||||
async def set_limits(
|
||||
payload: SetLimitsRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> ResourceLimitOut:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
limit = await service.set_limits(
|
||||
scope=payload.scope, scope_id=payload.scope_id or context.org_id,
|
||||
cpu=payload.cpu, memory_mb=payload.memory_mb, disk_mb=payload.disk_mb,
|
||||
timeout_seconds=payload.timeout_seconds,
|
||||
max_output_bytes=payload.max_output_bytes,
|
||||
network_policy=payload.network_policy,
|
||||
)
|
||||
return ResourceLimitOut(
|
||||
cpu=limit.cpu, memory_mb=limit.memory_mb, disk_mb=limit.disk_mb,
|
||||
timeout_seconds=limit.timeout_seconds, max_output_bytes=limit.max_output_bytes,
|
||||
network_policy=str(limit.network_policy),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workspaces
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/workspaces", response_model=WorkspaceOut, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_workspace(
|
||||
payload: CreateWorkspaceRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WorkspaceOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
|
||||
workspace = await service.create_workspace(
|
||||
organization_id=context.org_id,
|
||||
project_id=payload.project_id,
|
||||
template_id=payload.template_id,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action="sandbox.workspace_create", organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="workspace", target_id=workspace.id,
|
||||
metadata={"project_id": workspace.project_id, "provider": str(workspace.provider)},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return WorkspaceOut(
|
||||
id=workspace.id, project_id=workspace.project_id,
|
||||
template_id=workspace.template_id, provider=str(workspace.provider),
|
||||
status=str(workspace.status), created_at=workspace.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/workspaces", response_model=list[WorkspaceOut])
|
||||
async def list_workspaces(
|
||||
project_id: str = Query(min_length=1),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> list[WorkspaceOut]:
|
||||
workspaces = await service.list_workspaces(
|
||||
organization_id=context.org_id,
|
||||
project_id=project_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return [
|
||||
WorkspaceOut(
|
||||
id=w.id, project_id=w.project_id, template_id=w.template_id,
|
||||
provider=str(w.provider), status=str(w.status), created_at=w.created_at,
|
||||
)
|
||||
for w in workspaces
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/workspaces/{workspace_id}", response_model=WorkspaceOut)
|
||||
async def destroy_workspace(
|
||||
workspace_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WorkspaceOut:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
workspace = await service.destroy_workspace(
|
||||
organization_id=context.org_id, workspace_id=workspace_id
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.SANDBOX_DESTROY, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="workspace",
|
||||
target_id=workspace.id, ip=ip, user_agent=ua,
|
||||
)
|
||||
return WorkspaceOut(
|
||||
id=workspace.id, project_id=workspace.project_id,
|
||||
template_id=workspace.template_id, provider=str(workspace.provider),
|
||||
status=str(workspace.status), created_at=workspace.created_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sessions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions", response_model=SessionOut, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def start_session(
|
||||
payload: StartSessionRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> SessionOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
sandbox_session = await service.start_session(
|
||||
organization_id=context.org_id, workspace_id=payload.workspace_id
|
||||
)
|
||||
return SessionOut(
|
||||
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
|
||||
status=str(sandbox_session.status), started_at=sandbox_session.started_at,
|
||||
last_active_at=sandbox_session.last_active_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/stop", response_model=SessionOut)
|
||||
async def stop_session(
|
||||
session_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> SessionOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
sandbox_session = await service.stop_session(
|
||||
organization_id=context.org_id, session_id=session_id
|
||||
)
|
||||
return SessionOut(
|
||||
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
|
||||
status=str(sandbox_session.status), started_at=sandbox_session.started_at,
|
||||
last_active_at=sandbox_session.last_active_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commands & files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/commands", response_model=CommandRunOut)
|
||||
async def run_command(
|
||||
session_id: str,
|
||||
payload: RunCommandRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CommandRunOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
|
||||
run, _result = await service.run_command(
|
||||
organization_id=context.org_id,
|
||||
session_id=session_id,
|
||||
command=payload.command,
|
||||
user_id=context.user.id,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.SANDBOX_COMMAND, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="sandbox_session",
|
||||
target_id=session_id,
|
||||
# The command itself is recorded, but never a secret from the environment.
|
||||
metadata={"exit_code": run.exit_code, "duration_ms": run.duration_ms},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return CommandRunOut(
|
||||
id=run.id, command=run.command, exit_code=run.exit_code,
|
||||
stdout=run.stdout_ref, stderr=run.stderr_ref, duration_ms=run.duration_ms,
|
||||
truncated=run.truncated, created_at=run.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/commands", response_model=list[CommandRunOut])
|
||||
async def list_commands(
|
||||
session_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> list[CommandRunOut]:
|
||||
await service.get_session_or_404(
|
||||
organization_id=context.org_id, session_id=session_id
|
||||
)
|
||||
runs = await service.list_commands(session_id=session_id)
|
||||
return [
|
||||
CommandRunOut(
|
||||
id=r.id, command=r.command, exit_code=r.exit_code, stdout=r.stdout_ref,
|
||||
stderr=r.stderr_ref, duration_ms=r.duration_ms, truncated=r.truncated,
|
||||
created_at=r.created_at,
|
||||
)
|
||||
for r in runs
|
||||
]
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/files/read", response_model=FileReadOut)
|
||||
async def read_file(
|
||||
session_id: str,
|
||||
payload: ReadFileRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> FileReadOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
content = await service.read_file(
|
||||
organization_id=context.org_id, session_id=session_id, path=payload.path
|
||||
)
|
||||
return FileReadOut(path=payload.path, content=content)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/files/write", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def write_file(
|
||||
session_id: str,
|
||||
payload: WriteFileRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> None:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
await service.write_file(
|
||||
organization_id=context.org_id, session_id=session_id,
|
||||
path=payload.path, content=payload.content,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snapshots & preview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/snapshots",
|
||||
response_model=SnapshotOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_snapshot(
|
||||
session_id: str,
|
||||
payload: CreateSnapshotRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> SnapshotOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
snapshot = await service.snapshot(
|
||||
organization_id=context.org_id, session_id=session_id, name=payload.name
|
||||
)
|
||||
return SnapshotOut(
|
||||
id=snapshot.id, name=snapshot.name, created_at=snapshot.created_at
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/preview-url", response_model=PreviewUrlOut)
|
||||
async def get_preview_url(
|
||||
session_id: str,
|
||||
port: int = Query(ge=1, le=65535),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> PreviewUrlOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
preview = await service.get_preview_url(
|
||||
organization_id=context.org_id, session_id=session_id, port=port
|
||||
)
|
||||
return PreviewUrlOut(port=preview.port, url=preview.url)
|
||||
Reference in New Issue
Block a user