Files
smart-org/apps/api/app/workers/sandbox_gateway.py
T
mjalmoudy94 f1e5df29ce feat: realtime WebSocket layer for live events and streaming logs
- Backend: ConnectionManager, Redis-backed bus with in-process fallback,
  channel authorization, and typed emit helpers wired into workers
  (ai_runtime, sandbox_gateway), workforce/sandbox/billing/audit/error services.
- Frontend: single shared WS client with backoff + heartbeat, RealtimeProvider,
  useLiveChannel/useLiveEvent hooks, live indicators on task detail, manager
  chat, dashboard, traces, sandbox terminal, admin errors, and billing.
- Contracts: WsEvent/WsServerFrame/WsClientFrame types mirroring the port.

All 255 backend tests pass; web typecheck and build pass.
2026-10-13 12:56:36 +03:30

103 lines
4.2 KiB
Python

"""Sandbox gateway worker — housekeeping for the only sanctioned execution path.
Two jobs, both about not leaking resources:
1. **Reap idle sessions.** A sandbox session that nobody has touched for hours
is still consuming CPU and disk on the provider. The reaper stops it and
writes an audit event, because "the platform killed my sandbox" must be
answerable from the audit log.
2. **Report the policy in force.** The gateway is the one place where untrusted
code runs, so the worker logs the effective network policy and the absolute
caps at startup. An operator should be able to see the safety envelope
without reading the source.
The worker never executes a command. It only stops sessions — the command path
is ``SandboxService.run_command``, reachable from the API under a tenant check.
"""
from __future__ import annotations
from datetime import timedelta
from app.application.container import build_sandbox_service
from app.application.ports.realtime import emit_sandbox_reaped
from app.core.config import settings
from app.domain.audit.models import AuditAction, AuditEvent
from app.domain.base import utcnow
from app.infrastructure.repositories.audit import AuditRepository
from app.infrastructure.repositories.sandbox import WorkspaceRepository
from app.workers.base import WorkerOutcome, worker_session
#: Sessions are considered abandoned after this long with no activity.
DEFAULT_IDLE_MINUTES = 120
class SandboxGatewayWorker:
"""Stops idle sandbox sessions and records why."""
name = "sandbox-gateway"
async def reap(
self, *, idle_minutes: int = DEFAULT_IDLE_MINUTES, limit: int = 200
) -> WorkerOutcome:
outcome = WorkerOutcome(name=self.name)
idle_before = utcnow() - timedelta(minutes=max(1, idle_minutes))
async with worker_session() as session:
sandbox = build_sandbox_service(session)
workspaces = WorkspaceRepository(session)
audit = AuditRepository(session)
stopped = await sandbox.reap_idle_sessions(
idle_before=idle_before, limit=limit
)
for sandbox_session in stopped:
# Resolve the tenant for the audit trail. A session whose
# workspace has vanished is still stopped, just recorded
# without an organization.
workspace = await workspaces.get(sandbox_session.workspace_id)
await audit.record(
event=AuditEvent(
action=AuditAction.SANDBOX_REAP,
organization_id=workspace.organization_id if workspace else None,
target_type="sandbox_session",
target_id=sandbox_session.id,
metadata_json={
"workspace_id": sandbox_session.workspace_id,
"idle_minutes": idle_minutes,
"reason": "idle_timeout",
},
)
)
outcome.processed += 1
outcome.note(f"stopped session {sandbox_session.id}")
# A reaped sandbox is a tenant-visible fact: push it so the
# project page's terminal stops showing a live session.
await emit_sandbox_reaped(
organization_id=workspace.organization_id if workspace else None,
session_id=sandbox_session.id,
workspace_id=sandbox_session.workspace_id,
)
if not stopped:
outcome.skipped += 1
return outcome
def policy_report(self) -> WorkerOutcome:
"""Describe the safety envelope this gateway is running under."""
outcome = WorkerOutcome(name=self.name)
outcome.note(f"sandbox provider = {settings.sandbox_provider}")
outcome.note(
f"command timeout = {settings.sandbox_command_timeout_seconds}s "
f"(absolute cap enforced in the service layer)"
)
outcome.note(f"max output = {settings.sandbox_max_output_bytes} bytes")
outcome.note("egress policy default = deny_all (set per resource limit)")
return outcome
__all__ = ["DEFAULT_IDLE_MINUTES", "SandboxGatewayWorker"]