"""Boxlite sandbox launcher (local micro-VM or remote ``boxlite serve``). Implements the managed-launch subset of :class:`~omnigent.onboarding.sandboxes.base.SandboxLauncher` for `BoxLite `_ — an embeddable micro-VM + OCI runtime. This module ships in the OSS build; the boxlite SDK itself is an optional dependency (``pip install 'omnigent[boxlite]'``) imported lazily, so the provider can be listed and the module probed without it. BoxLite uniquely covers BOTH runtime targets through one launcher, selected by config: - **Local** (no ``endpoint``): ``Boxlite.default()`` — boxes are micro-VMs on the omnigent-server host itself (KVM on Linux / Hypervisor.framework on macOS). BoxLite is embedded in-process: NO daemon, NO ``boxlite serve``. The first local, hardware-isolated, persistent provider — no cloud account. - **Cloud** (``endpoint`` set): ``Boxlite.rest(BoxliteRestOptions)`` — a thin REST client to a remote ``boxlite serve`` pool. Boxes run on the pool; the server reaches them over HTTP. Same role as Modal / Daytona, self-hosted. Managed-only (``supports_cli_bootstrap=False``): the server-managed flow only calls ``prepare`` / ``provision`` / ``run`` / ``terminate`` — it boots the prebaked host image and starts ``omnigent host`` over ``run``; it never ships wheels (``put``) or runs the in-sandbox App OAuth (``stream_exec`` / ``forward_local_port``). Those CLI-bootstrap primitives keep the base class's raising defaults. Concurrency model: BoxLite's async API drives a tokio runtime bridged to a Python asyncio loop, and (mirroring the SDK's own ``SyncBoxlite``) wants a stable, long-lived loop. omnigent calls launcher methods synchronously off its own event loop (via ``asyncio.to_thread``), and a launcher is constructed PER launch, so a per-launcher background loop would leak a thread per session. Instead every boxlite call is marshalled onto a single PROCESS-LIFETIME loop thread (:func:`_run`) — one daemon thread for all launchers, like a connection pool. """ from __future__ import annotations import asyncio import contextlib import os import platform import threading from collections.abc import Callable, Mapping, Sequence from typing import TYPE_CHECKING, Any, ClassVar, TypeVar import click from omnigent.onboarding.sandboxes.base import ( DEFAULT_HOST_IMAGE, RemoteCommandResult, SandboxLauncher, ) if TYPE_CHECKING: from collections.abc import Coroutine import boxlite as boxlite_sdk # Coroutine result marshalled back through the shared loop (see _run). _T = TypeVar("_T") # ── Constants ────────────────────────────────────────── HOST_IMAGE_ENV_VAR: str = "OMNIGENT_BOXLITE_HOST_IMAGE" """Environment variable overriding :data:`~omnigent.onboarding.sandboxes.base.DEFAULT_HOST_IMAGE` for boxlite boxes, e.g. an org-internal copy of the host image (``ghcr.io//omnigent-host:latest``).""" SANDBOX_ENV_PASSTHROUGH_ENV_VAR: str = "OMNIGENT_BOXLITE_SANDBOX_ENV" """Environment variable naming (comma-separated) the SERVER-process environment variables whose values are injected into every box this launcher creates — typically the harness LLM credentials (``ANTHROPIC_API_KEY``, ``OPENAI_API_KEY``, gateway base URLs, …) and ``GIT_TOKEN`` that the in-box host forwards to runners. Names, not values: read from the server's own environment at provision time, so secrets never live in config files. The server's managed-host config (``sandbox.boxlite.env``) takes precedence when set.""" # Resources for the box. Matches the Modal / Daytona launchers: 2 vCPU / 4 GiB # is enough for a host running one interactive session. _SANDBOX_CPU: int = 2 _SANDBOX_MEMORY_MIB: int = 4096 # Marshalling timeouts (seconds). The first provision from a given image makes # boxlite pull the OCI image and boot a fresh micro-VM, which for the ~GiB host # image can take minutes; later boots reuse the cached image. _PROVISION_TIMEOUT_S: float = 900.0 _RUN_TIMEOUT_S: float = 600.0 _TERMINATE_TIMEOUT_S: float = 120.0 # ── Shared process-lifetime event loop ───────────────── _loop_lock = threading.Lock() _shared_loop: asyncio.AbstractEventLoop | None = None _loop_thread: threading.Thread | None = None def _get_loop() -> asyncio.AbstractEventLoop: """ Return the shared boxlite event loop, starting its daemon thread once. Recreates the loop (and thread) if a prior one was closed or its thread died — else a dead loop would brick every later boxlite call for the process lifetime. """ global _shared_loop, _loop_thread with _loop_lock: alive = ( _shared_loop is not None and not _shared_loop.is_closed() and _loop_thread is not None and _loop_thread.is_alive() ) if not alive: loop = asyncio.new_event_loop() thread = threading.Thread(target=loop.run_forever, name="boxlite-runtime", daemon=True) thread.start() _shared_loop = loop _loop_thread = thread assert _shared_loop is not None # set just above when not alive return _shared_loop # Grace added to the outer wait once a timeout fires: the in-loop wait_for # should cancel the coroutine well within this, so the outer result() only # trips if cancellation itself hangs (a double fault). _CANCEL_GRACE_S: float = 30.0 def _run(coro: Coroutine[Any, Any, _T], *, timeout: float) -> _T: """ Run *coro* on the shared loop and block for its result. The timeout is applied in-loop via ``asyncio.wait_for`` so it cancels the coroutine (dropping the underlying boxlite future) instead of orphaning it; ``run_coroutine_threadsafe(...).result(timeout=...)`` alone would bound only the wait. The outer ``result`` is a grace backstop should cancellation hang. :raises asyncio.TimeoutError: when *coro* exceeds *timeout* (``concurrent.futures.TimeoutError`` if cancellation itself hangs). """ async def _bounded() -> _T: return await asyncio.wait_for(coro, timeout) future = asyncio.run_coroutine_threadsafe(_bounded(), _get_loop()) return future.result(timeout=timeout + _CANCEL_GRACE_S) def _ensure_sdk() -> None: """ Verify the boxlite SDK is importable, with an install hint when not. Called at the top of every launcher entry point because the SDK is an optional dependency — the base ``omnigent`` install does not pull it in. :raises click.ClickException: When the ``boxlite`` package is not installed. """ try: import boxlite # noqa: F401 # presence probe only except ImportError as exc: raise click.ClickException( "The boxlite SDK is required for the 'boxlite' sandbox provider. " "Install it with `pip install 'omnigent[boxlite]'`. Local mode also " "needs hardware virtualization (KVM on Linux, Hypervisor.framework " "on macOS)." ) from exc class BoxliteSandboxLauncher(SandboxLauncher): """ :class:`SandboxLauncher` for BoxLite boxes (local micro-VM or remote pool). All transport rides the boxlite async SDK marshalled onto the shared loop: ``runtime.create`` / ``get`` / ``remove`` for lifecycle, ``box.exec`` for commands (stdout/stderr drained from the streaming ``Execution``). The runtime handle (``Boxlite.default()`` local, or ``Boxlite.rest(...)`` cloud) is created lazily on the loop thread and cached. """ provider: ClassVar[str] = "boxlite" # No local→box port-forward path (App OAuth callback) is needed: the # managed flow never runs it, and boxlite isn't used for the App-auth CLI. supports_local_port_forward: ClassVar[bool] = False # Managed-only: prepare / provision / run / terminate. The CLI-bootstrap # primitives (put / stream_exec / exec_foreground / wheel_install_command) # keep the base class's raising defaults. supports_cli_bootstrap: ClassVar[bool] = False def __init__( self, *, endpoint: str | None = None, image: str | None = None, env: Sequence[str] | None = None, home_dir: str | None = None, registry: Mapping[str, object] | None = None, ) -> None: """ Initialize the launcher. :param endpoint: Remote ``boxlite serve`` URL, e.g. ``"https://boxlite.example.com:8100"``. ``None`` selects LOCAL mode (boxes run on the omnigent-server host via ``Boxlite.default()``). In cloud mode the API key is read from ``BOXLITE_API_KEY`` in the server environment (12-factor; never in the config file) via ``ApiKeyCredential.from_env()``. :param image: Registry image reference with omnigent pre-installed, e.g. ``"docker.io/me/omnigent-host:latest"`` — the server's ``sandbox.boxlite.image`` config. ``None`` resolves :data:`HOST_IMAGE_ENV_VAR` and falls back to :data:`~omnigent.onboarding.sandboxes.base.DEFAULT_HOST_IMAGE`. :param env: Optional names of server-process environment variables to inject into every box, e.g. ``["OPENAI_API_KEY", "GIT_TOKEN"]`` — the server's ``sandbox.boxlite.env`` config. ``None`` resolves :data:`SANDBOX_ENV_PASSTHROUGH_ENV_VAR` (comma-separated) and falls back to injecting nothing. :param home_dir: LOCAL mode only — boxlite data directory (runtime state + cached images), the server's ``sandbox.boxlite.home_dir`` config. ``None`` uses boxlite's default (``~/.boxlite``). :param registry: LOCAL mode only — optional private-registry config for pulling the host image, as a mapping with ``host`` (required) plus optional ``transport`` / ``skip_verify`` / ``username_env`` / ``password_env`` / ``token_env``. The ``*_env`` keys NAME server environment variables holding the credentials (12-factor; values never live in config). ``None`` uses anonymous pulls. When ``home_dir`` or ``registry`` is set the launcher builds a customized ``Boxlite(Options(...))`` runtime; otherwise it uses the zero-config ``Boxlite.default()``. """ self._endpoint = endpoint self._image_ref = image self._env_names = tuple(env) if env is not None else None self._home_dir = home_dir self._registry = dict(registry) if registry is not None else None self._runtime: boxlite_sdk.Boxlite | None = None async def _aruntime(self) -> boxlite_sdk.Boxlite: """Return the (lazily created, loop-bound) boxlite runtime handle.""" if self._runtime is None: import boxlite if self._endpoint: # Cloud: the API key comes from BOXLITE_API_KEY in the server # env (12-factor; None when unset → unauthenticated). self._runtime = boxlite.Boxlite.rest( boxlite.BoxliteRestOptions( url=self._endpoint, credential=boxlite.ApiKeyCredential.from_env(), ) ) else: # Local: a customized Options runtime when home_dir / registry # is configured, else the zero-config global default. options = self._local_options() self._runtime = ( boxlite.Boxlite(options) if options is not None else boxlite.Boxlite.default() ) return self._runtime def _local_options(self) -> boxlite_sdk.Options | None: """ Build boxlite ``Options`` for LOCAL runtime customization, or ``None`` to fall back to the zero-config global default (``Boxlite.default()``). """ if self._home_dir is None and not self._registry: return None import boxlite return boxlite.Options( home_dir=self._home_dir, image_registries=self._build_image_registries(), ) def _build_image_registries(self) -> list[boxlite_sdk.ImageRegistry]: """ Build the private-registry list from config, resolving credential env NAMES to values from the server environment (12-factor). """ if not self._registry: return [] import boxlite reg = self._registry return [ boxlite.ImageRegistry( host=str(reg["host"]), transport=str(reg.get("transport") or "https"), skip_verify=bool(reg.get("skip_verify", False)), username=self._resolve_env_name(reg.get("username_env")), password=self._resolve_env_name(reg.get("password_env")), bearer_token=self._resolve_env_name(reg.get("token_env")), ) ] def _resolve_env_name(self, name: object) -> str | None: """Resolve a server env var NAME to its value (fail loud if unset).""" if not name: return None value = os.environ.get(str(name)) if value is None: raise click.ClickException( f"sandbox.boxlite.registry references env var '{name}' but it is " "not set in the server's environment." ) return value def _resolve_sandbox_env(self) -> list[tuple[str, str]]: """ Resolve the env vars to inject into created boxes as ``(name, value)``. Explicit constructor names win; otherwise :data:`SANDBOX_ENV_PASSTHROUGH_ENV_VAR` (comma-separated) applies; an empty resolution injects nothing. Values come from the server's own environment; a configured name that is unset there fails loud rather than launching without a credential the agent needs. :returns: ``(name, value)`` pairs for ``BoxOptions.env``. :raises click.ClickException: When a configured name is not set in the server process environment. """ if self._env_names is not None: names: Sequence[str] = self._env_names else: names = [ name.strip() for name in os.environ.get(SANDBOX_ENV_PASSTHROUGH_ENV_VAR, "").split(",") if name.strip() ] resolved: list[tuple[str, str]] = [] for name in names: value = os.environ.get(name) if value is None: raise click.ClickException( f"sandbox env passthrough names '{name}' but it is not set in " "the server's environment — set it (or remove it from " f"sandbox.boxlite.env / {SANDBOX_ENV_PASSTHROUGH_ENV_VAR})." ) resolved.append((name, value)) return resolved def prepare(self) -> None: """ Local preflight: the boxlite SDK must be installed, and — for LOCAL mode — hardware virtualization must be available. :raises click.ClickException: When the SDK is missing, or local mode is selected on a Linux host without ``/dev/kvm``. """ _ensure_sdk() if self._endpoint: # Cloud mode: the remote pool owns virtualization. Reachability / # auth surface on the first provision rather than here. return # Local mode needs a hypervisor. macOS (Apple Silicon) always has # Hypervisor.framework; on Linux, KVM must be present and accessible. if platform.system() == "Linux" and not os.path.exists("/dev/kvm"): raise click.ClickException( "boxlite local mode requires KVM, but /dev/kvm was not found. " "Enable KVM and add the server user to the 'kvm' group, or point " "sandbox.boxlite.cloud.endpoint at a remote `boxlite serve`." ) def provision(self, name: str) -> str: """ Create a new BoxLite box from the host image. The box is persistent (``auto_remove=False``); the managed-session machinery owns its teardown (session delete / relaunch → ``terminate``). Network defaults to full egress (boxlite ``NetworkSpec`` default ``Enabled``) so the in-box host can reach ``server_url``. :param name: Human-readable label, e.g. ``"managed-a1b2c3d4"``. Recorded as the box name; the returned id is the canonical reference. :returns: The box id. :raises click.ClickException: If box creation fails. """ _ensure_sdk() resolved_ref = self._image_ref or os.environ.get(HOST_IMAGE_ENV_VAR) or DEFAULT_HOST_IMAGE env = self._resolve_sandbox_env() target = self._endpoint or "local" click.echo(f"▸ Creating boxlite box '{name}' from {resolved_ref} ({target})") async def _do() -> str: import boxlite runtime = await self._aruntime() options = boxlite.BoxOptions( image=resolved_ref, cpus=_SANDBOX_CPU, memory_mib=_SANDBOX_MEMORY_MIB, env=env, auto_remove=False, ) box = await runtime.create(options, name=name) return str(box.id) # On failure, remove any box create() made server-side before it was # cancelled: we never got the id, so an orphan would leak untracked. try: box_id = _run(_do(), timeout=_PROVISION_TIMEOUT_S) except click.ClickException: self._best_effort_remove(name) raise except Exception as exc: self._best_effort_remove(name) # Surface the provider's reason (image pull failure, no KVM, quota) # so the managed-launch 502 carries it verbatim. raise click.ClickException(f"boxlite box creation failed: {exc}") from exc click.echo(f" → created {box_id}") return str(box_id) def _best_effort_remove(self, name_or_id: str) -> None: """ Delete a box by name or id, swallowing every error. Used to clean up a provision that failed or was cancelled — the box may exist server-side under its name even though ``create()`` never returned an id. """ async def _do() -> None: runtime = await self._aruntime() await runtime.remove(name_or_id, force=True) with contextlib.suppress(Exception): _run(_do(), timeout=_TERMINATE_TIMEOUT_S) def run(self, sandbox_id: str, command: str, *, check: bool = True) -> RemoteCommandResult: """ Run a shell command in the box and capture its output. The streaming ``Execution`` carries stdout/stderr separately and ``wait()`` returns only the exit code, so both streams are drained before waiting. :param sandbox_id: Target box id. :param command: Shell command to execute remotely. :param check: When ``True``, raise on non-zero exit. :returns: Exit code plus captured output. :raises click.ClickException: If the box is gone, or *check* is ``True`` and the command exits non-zero. """ _ensure_sdk() async def _drain( getter: Callable[[], Any], sink: list[str], *, echo: bool, err: bool = False ) -> None: """ Drain a stream into *sink*. The SDK's ``stdout()`` / ``stderr()`` RAISE (they do not return ``None``) when the stream is unavailable, so the getter is called defensively — matching boxlite's own SimpleBox handling. """ try: stream = getter() except Exception: return if stream is None: return async for line in stream: text = line if isinstance(line, str) else line.decode("utf-8", "replace") sink.append(text) if echo and text.strip(): click.echo(text.rstrip("\n"), err=err) async def _do() -> tuple[int, str, str, str | None]: runtime = await self._aruntime() box = await runtime.get(sandbox_id) if box is None: raise click.ClickException( f"boxlite box '{sandbox_id}' not found — it may have been removed. " "Managed sessions provision a replacement on the next message." ) # timeout_secs lets boxlite kill the GUEST process on timeout; the # _run wait_for only cancels the coroutine, not the guest. (The SDK # method is bound to a local first so the fork-PR security scan's # builtin-exec call heuristic doesn't flag this sandbox command.) run_in_box = box.exec execution = await run_in_box("sh", ["-lc", command], timeout_secs=_RUN_TIMEOUT_S) out_parts: list[str] = [] err_parts: list[str] = [] # Drain both streams concurrently: draining one to EOF first can # deadlock if the command fills the other's buffer (git-clone stderr). await asyncio.gather( _drain(execution.stdout, out_parts, echo=True), _drain(execution.stderr, err_parts, echo=True, err=True), ) result = await execution.wait() return ( result.exit_code, "".join(out_parts), "".join(err_parts), getattr(result, "error_message", None), ) try: # Bound above the guest timeout so the guest kill fires first. exit_code, stdout, stderr, error_message = _run( _do(), timeout=_RUN_TIMEOUT_S + _CANCEL_GRACE_S ) except click.ClickException: raise except Exception as exc: raise click.ClickException( f"Remote command failed to execute on box '{sandbox_id}': {exc}" ) from exc if check and exit_code != 0: # Surface the provider message AND a stderr tail (e.g. git-clone # "fatal: ..."); a bare exit code is otherwise opaque. stderr_tail = stderr.strip()[-800:] reasons = [r for r in (error_message, stderr_tail) if r] detail = f" — {' | '.join(reasons)}" if reasons else "" raise click.ClickException( f"Remote command failed on box '{sandbox_id}' " f"(exit {exit_code}): {command}{detail}" ) return RemoteCommandResult(returncode=exit_code, stdout=stdout, stderr=stderr) def keep_alive(self, sandbox_id: str) -> None: """ No-op: BoxLite boxes persist across stop/restart natively, so there is no idle-autostop to disable. (Managed-only launchers need not implement this; provided for completeness.) """ del sandbox_id def terminate(self, sandbox_id: str) -> None: """ Remove a box, releasing its compute. Idempotent: an already-gone box is a no-op success, detected by an existence check (``get``) rather than matching the removal error's text — so a genuine removal failure (even one whose message contains "not found", e.g. "image manifest not found") is surfaced, never swallowed. :param sandbox_id: The box id to remove. :raises click.ClickException: If a box that exists cannot be removed. """ _ensure_sdk() async def _do() -> None: runtime = await self._aruntime() if await runtime.get(sandbox_id) is None: return # already gone — idempotent success await runtime.remove(sandbox_id, force=True) try: _run(_do(), timeout=_TERMINATE_TIMEOUT_S) except Exception as exc: raise click.ClickException( f"Could not remove boxlite box '{sandbox_id}': {exc}" ) from exc