- 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.
103 lines
4.2 KiB
Python
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"]
|