""" Modal sandbox launcher. Implements :class:`~omnigent.onboarding.sandboxes.base.SandboxLauncher` for `Modal `_ sandboxes. This module ships in the OSS build; the Modal SDK itself is an optional dependency (``pip install 'omnigent[modal]'``) imported lazily, so the provider can be listed and the module probed without it. Platform constraints that shape this launcher: - **24-hour lifetime.** Modal caps sandbox lifetime at 24 hours; :meth:`ModalSandboxLauncher.provision` requests that maximum and :meth:`ModalSandboxLauncher.keep_alive` can only restate the cap — a Modal-hosted Omnigent host must be re-created daily. - **No inbound port forwarding.** Modal tunnels expose sandbox ports to the public internet but provide no local→sandbox path, so the in-sandbox App OAuth flow (which forwards the browser's callback port) is unsupported: ``supports_local_port_forward`` stays ``False`` and the CLI skips the in-sandbox App OAuth step automatically. - **No kill handle on exec'd processes.** The SDK's process handle exposes wait/poll/streams only, so :meth:`ModalSandboxLauncher.exec_foreground` records the remote pid in a pidfile and kills it via a second exec when the local side is interrupted. """ from __future__ import annotations import os from collections.abc import Sequence from pathlib import Path from typing import TYPE_CHECKING, ClassVar import click from omnigent.onboarding.sandboxes.base import ( DEFAULT_HOST_IMAGE, RemoteCommandResult, RemoteProcess, SandboxLauncher, host_image_wheel_install_command, ) if TYPE_CHECKING: from collections.abc import Iterator import modal # ── Constants ────────────────────────────────────────── MODAL_APP_NAME: str = "omnigent-sandboxes" """Modal App that owns every sandbox this launcher creates. Sandboxes must belong to an App; a single shared, lazily-created App keeps them grouped in Modal's dashboard.""" MAX_SANDBOX_LIFETIME_S: int = 24 * 60 * 60 """Sandbox ``timeout`` requested at creation — Modal's hard platform maximum (24 hours). There is no way to extend a sandbox past it.""" HOST_IMAGE_ENV_VAR: str = "OMNIGENT_MODAL_HOST_IMAGE" """Environment variable overriding :data:`DEFAULT_HOST_IMAGE`, e.g. for an org-internal copy of the host image (``ghcr.io//omnigent-host:latest``).""" REGISTRY_SECRET_ENV_VAR: str = "OMNIGENT_MODAL_REGISTRY_SECRET" """Environment variable naming a Modal secret (https://modal.com/secrets) holding static registry credentials — ``REGISTRY_USERNAME`` / ``REGISTRY_PASSWORD`` (for GHCR: a PAT with ``read:packages``) — required when the host image lives in a private registry. Unset means an anonymous pull.""" SANDBOX_SECRETS_ENV_VAR: str = "OMNIGENT_MODAL_SANDBOX_SECRETS" """Environment variable naming Modal secrets (comma-separated) whose env vars are injected into every sandbox this launcher creates — typically the harness LLM credentials (``ANTHROPIC_API_KEY``, ``OPENAI_API_KEY``, ``CLAUDE_CODE_OAUTH_TOKEN``, gateway base URLs, …) that the in-sandbox host forwards to runners. Distinct from :data:`REGISTRY_SECRET_ENV_VAR` (image-pull credentials): these become the WORKLOAD's environment. The server's managed-host config (``sandbox.modal.secrets``) takes precedence when set.""" # Resources for the sandbox. Modal's defaults (0.125 CPU cores) starve # the Omnigent host's runner + harness processes; 2 vCPU / 4 GiB is # enough for a host running one interactive session. _SANDBOX_CPU: float = 2.0 _SANDBOX_MEMORY_MIB: int = 4096 # Where exec_foreground records the remote process's pid so Ctrl-C on # the local side can kill it (the SDK has no kill API for exec'd # processes). One foreground process per sandbox at a time, by design — # it holds the local terminal. _FOREGROUND_PIDFILE: str = "/tmp/oa-foreground.pid" def _ensure_sdk() -> None: """ Verify the Modal 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 ``modal`` package is not installed. """ try: import modal # noqa: F401 # presence probe only except ImportError as exc: raise click.ClickException( "The Modal SDK is required for the 'modal' sandbox provider. " "Install it with `pip install 'omnigent[modal]'`, then " "authenticate with `modal token new`." ) from exc def _has_modal_credentials() -> bool: """ Detect whether Modal credentials are available to the SDK. Mirrors the SDK's own resolution order: the ``MODAL_TOKEN_ID`` / ``MODAL_TOKEN_SECRET`` env-var pair takes precedence, then the token file (``MODAL_CONFIG_PATH`` override or the documented default ``~/.modal.toml``). :returns: ``True`` if either credential source is present. """ if os.environ.get("MODAL_TOKEN_ID") and os.environ.get("MODAL_TOKEN_SECRET"): return True config_path = os.environ.get("MODAL_CONFIG_PATH") token_file = Path(config_path) if config_path else Path.home() / ".modal.toml" return token_file.is_file() def _lookup_sandbox(sandbox_id: str) -> modal.Sandbox: """ Resolve a sandbox id to a live SDK handle. :param sandbox_id: Modal sandbox object id, e.g. ``"sb-a1b2c3"``. :returns: The sandbox handle. :raises click.ClickException: When no sandbox with that id exists (including ones already reaped after their 24h lifetime). """ _ensure_sdk() import modal try: return modal.Sandbox.from_id(sandbox_id) except modal.exception.NotFoundError as exc: raise click.ClickException( f"Modal sandbox '{sandbox_id}' not found — it may have passed its " "24-hour lifetime. Create a fresh one with " "`omnigent sandbox create --provider modal`." ) from exc def _build_sandbox_image(image_ref: str | None = None) -> modal.Image: """ Resolve the sandbox image definition. Pulls the prebaked Omnigent host image — the full omnigent install plus the tools a host needs at runtime: ``git`` (workspaces), ``tmux`` (terminal sessions spawned by native harnesses), ``curl`` + CA certificates. Booting from a prebaked image makes sandbox creation a pull instead of an in-sandbox dependency install; the CLI flow's locally-built wheels still overlay the baked install (see :meth:`ModalSandboxLauncher.wheel_install_command`). Resolution order for the image reference: the explicit *image_ref* (e.g. the server's managed-host ``sandbox.modal.image`` config) → :data:`HOST_IMAGE_ENV_VAR` → the official :data:`DEFAULT_HOST_IMAGE`. For images in private registries, :data:`REGISTRY_SECRET_ENV_VAR` names a Modal secret with ``REGISTRY_USERNAME`` / ``REGISTRY_PASSWORD`` keys (for GHCR: a PAT with ``read:packages``) — applied to explicit refs and env/default refs alike. :param image_ref: Explicit registry image reference, e.g. ``"docker.io/me/omnigent-host:latest"``, or ``None`` to resolve from the environment / official default. :returns: The (lazy) image definition; Modal pulls it on first use. """ import modal resolved_ref = image_ref or os.environ.get(HOST_IMAGE_ENV_VAR) or DEFAULT_HOST_IMAGE secret_name = os.environ.get(REGISTRY_SECRET_ENV_VAR) secret = modal.Secret.from_name(secret_name) if secret_name else None return modal.Image.from_registry(resolved_ref, secret=secret) def _echo_lines(stream: str) -> None: """ Echo a captured remote output stream line-by-line, dropping pure-whitespace lines. :param stream: Captured stdout or stderr from a remote command. """ for line in stream.splitlines(): if line.strip(): click.echo(line) class _ModalRemoteProcess(RemoteProcess): """ :class:`RemoteProcess` over a Modal container process handle. The handle's stdout stream carries the combined output (the spawn site merges stderr in-shell or via a PTY). """ def __init__(self, process: modal.container_process.ContainerProcess[str]) -> None: """ Wrap a running exec'd process. :param process: Handle returned by ``Sandbox.exec``. """ self._process = process # Materialize the line iterator once so repeated `lines` reads # resume the same stream (the RemoteProcess contract). self._lines: Iterator[str] = iter(process.stdout) @property def lines(self) -> Iterator[str]: """ The process's combined-output line iterator (same object on every access). :returns: Line iterator over the process's stdout. """ return self._lines def wait(self) -> int: """ Block until the process exits. :returns: The process's exit code. """ return self._process.wait() def close(self) -> None: """ Best-effort cleanup. DEVIATION from the base contract: Modal's SDK exposes no way to kill an exec'd process (the handle has only wait/poll/streams), so a still-running process is left to die with the sandbox (at most 24 hours). In practice this path is unreachable: the only ``stream_exec`` consumer is the OAuth login flow, which the bootstrap blocks on Modal before any process is spawned. """ # Nothing to reap if the process already exited; nothing we CAN # do if it hasn't (see docstring). self._process.poll() class ModalSandboxLauncher(SandboxLauncher): """ :class:`SandboxLauncher` for Modal sandboxes. All transport rides the Modal SDK: ``Sandbox.exec`` for commands (argv-style ``bash -lc``, no remote quoting pitfalls), ``sandbox.filesystem`` for file shipping. Handles are cached per sandbox id to avoid a server round-trip on every primitive. """ provider: ClassVar[str] = "modal" # Public PyPI is reachable from the local wheel build (no corp-proxy # requirement for Modal users); ambient uv config applies. wheel_build_index_url: ClassVar[str | None] = None # Modal tunnels are sandbox→public only; there is no local→sandbox # path, so the App OAuth flow is unsupported (use --no-auth). supports_local_port_forward: ClassVar[bool] = False def __init__(self, *, image: str | None = None, secrets: Sequence[str] | None = None) -> None: """ Initialize the launcher. :param image: Optional registry image reference to provision sandboxes from, e.g. ``"docker.io/me/omnigent-host:latest"`` — the server's managed-host ``sandbox.modal.image`` config. ``None`` resolves :data:`HOST_IMAGE_ENV_VAR` and falls back to the official :data:`DEFAULT_HOST_IMAGE` (see :func:`_build_sandbox_image`). :param secrets: Optional Modal secret names whose env vars are injected into every sandbox, e.g. ``["omnigent-llm"]`` — the server's managed-host ``sandbox.modal.secrets`` config. ``None`` resolves :data:`SANDBOX_SECRETS_ENV_VAR` (comma-separated) and falls back to no injected secrets. """ self._image_ref = image self._secret_names = tuple(secrets) if secrets is not None else None self._sandboxes: dict[str, modal.Sandbox] = {} def _resolve_sandbox_secrets(self) -> list[modal.Secret]: """ Resolve the Modal secrets to inject into created sandboxes. Explicit constructor names win; otherwise :data:`SANDBOX_SECRETS_ENV_VAR` (comma-separated) applies; an empty resolution injects nothing. :returns: Secret handles for ``Sandbox.create(secrets=…)``. """ import modal if self._secret_names is not None: names: Sequence[str] = self._secret_names else: names = [ name.strip() for name in os.environ.get(SANDBOX_SECRETS_ENV_VAR, "").split(",") if name.strip() ] return [modal.Secret.from_name(name) for name in names] def _resolve(self, sandbox_id: str) -> modal.Sandbox: """ Return the cached handle for *sandbox_id*, looking it up on first use. :param sandbox_id: Modal sandbox object id, e.g. ``"sb-a1b2c3"``. :returns: The sandbox handle. :raises click.ClickException: When the sandbox does not exist. """ handle = self._sandboxes.get(sandbox_id) if handle is None: handle = _lookup_sandbox(sandbox_id) self._sandboxes[sandbox_id] = handle return handle def prepare(self) -> None: """ Local preflight: the Modal SDK must be installed and credentials available. :raises click.ClickException: When the SDK is missing or no credentials are found. """ _ensure_sdk() if not _has_modal_credentials(): raise click.ClickException( "No Modal credentials found. Run `modal token new` to " "authenticate (or set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET)." ) def provision(self, name: str) -> str: """ Create a new Modal sandbox under the shared Omnigent App. The sandbox is created at Modal's maximum lifetime (24 hours) with a ``sleep infinity`` entrypoint so it stays up for the full window regardless of exec activity. :param name: Human-readable label, e.g. ``"omnigent-host"``. Modal sandbox *names* must be unique per App, so the label rides a tag instead; the returned id is the canonical reference. :returns: The sandbox object id, e.g. ``"sb-a1b2c3"``. """ _ensure_sdk() import modal click.echo(f"▸ Creating Modal sandbox '{name}' (lives at most 24 hours)") app = modal.App.lookup(MODAL_APP_NAME, create_if_missing=True) image = _build_sandbox_image(self._image_ref) secrets = self._resolve_sandbox_secrets() handle = modal.Sandbox.create( # Entrypoint: hold the sandbox open for its full lifetime; # all real work arrives via exec. "sleep", "infinity", app=app, image=image, timeout=MAX_SANDBOX_LIFETIME_S, cpu=_SANDBOX_CPU, memory=_SANDBOX_MEMORY_MIB, # Workload env: the deployment's harness credentials (LLM # keys / gateway URLs) injected from named Modal secrets; # None keeps the SDK default when nothing is configured. secrets=secrets or None, ) handle.set_tags({"omnigent-name": name}) self._sandboxes[handle.object_id] = handle click.echo(f" → created {handle.object_id}") return handle.object_id def attach(self, sandbox_id: str) -> None: """ Validate that an existing sandbox is still running. :param sandbox_id: The sandbox to attach to. :raises click.ClickException: When the sandbox is missing or has terminated (Modal sandboxes live at most 24 hours). """ click.echo(f"▸ Reusing existing Modal sandbox '{sandbox_id}'") handle = self._resolve(sandbox_id) # poll() returns None while the sandbox is running. if handle.poll() is not None: raise click.ClickException( f"Modal sandbox '{sandbox_id}' has terminated (sandboxes live " "at most 24 hours). Create a fresh one with " "`omnigent sandbox create --provider modal`." ) def keep_alive(self, sandbox_id: str) -> None: """ Nothing to configure: there is no idle autostop to disable, and lifetime is fixed at creation (provision already requests the 24-hour platform maximum). Surfaces the cap so users aren't surprised when the host disappears. :param sandbox_id: The sandbox (unused beyond the message; present to satisfy the launcher contract). """ click.echo( f" → Modal caps sandbox lifetime at 24 hours; re-run `omnigent " f"sandbox create --provider modal` for a fresh host after " f"'{sandbox_id}' expires." ) def terminate(self, sandbox_id: str) -> None: """ Terminate a sandbox, releasing its compute. Idempotent from the caller's perspective: a sandbox that no longer exists (already terminated or aged past the 24h cap) is treated as success — the desired end state holds. :param sandbox_id: The sandbox to terminate, e.g. ``"sb-a1b2c3"``. """ _ensure_sdk() import modal try: handle = modal.Sandbox.from_id(sandbox_id) except modal.exception.NotFoundError: return handle.terminate() self._sandboxes.pop(sandbox_id, None) def run(self, sandbox_id: str, command: str, *, check: bool = True) -> RemoteCommandResult: """ Run a shell command in the sandbox and capture its output. :param sandbox_id: Target sandbox. :param command: Shell command to execute remotely. ``bash -lc`` wraps it so login PATH applies. :param check: When ``True``, raise on non-zero exit. :returns: Exit code plus captured stdout/stderr. :raises click.ClickException: If *check* is ``True`` and the command exits non-zero. """ handle = self._resolve(sandbox_id) process = handle.exec("bash", "-lc", command) stdout = process.stdout.read() stderr = process.stderr.read() returncode = process.wait() _echo_lines(stdout) _echo_lines(stderr) if check and returncode != 0: raise click.ClickException( f"Remote command failed on sandbox '{sandbox_id}' (exit {returncode}): {command}" ) return RemoteCommandResult(returncode=returncode, stdout=stdout, stderr=stderr) def put(self, sandbox_id: str, local_path: Path, remote_path: str) -> None: """ Copy a local file into the sandbox via Modal's filesystem API. :param sandbox_id: Target sandbox. :param local_path: Local file to read. :param remote_path: Absolute destination path on the sandbox, e.g. ``"/tmp/oa-wheels.tgz"`` (the filesystem API does not expand ``~``). """ handle = self._resolve(sandbox_id) handle.filesystem.copy_from_local(str(local_path), remote_path) def stream_exec(self, sandbox_id: str, command: str, *, pty: bool = False) -> RemoteProcess: """ Spawn a command in the sandbox and stream its output line by line. :param sandbox_id: Target sandbox. :param command: Shell command to execute remotely. :param pty: When ``True``, allocate a remote PTY (output arrives pre-merged on the terminal stream). :returns: Handle over the streaming process. """ handle = self._resolve(sandbox_id) # Without a PTY, stdout/stderr arrive on separate streams and # the RemoteProcess contract wants combined output — merge # in-shell. A PTY already interleaves both. remote = command if pty else f"{command} 2>&1" process = handle.exec("bash", "-lc", remote, pty=pty) return _ModalRemoteProcess(process) def exec_foreground(self, sandbox_id: str, command: str) -> int: """ Run *command* in the sandbox, echoing its output to the local terminal until it exits; Ctrl-C kills the remote process and re-raises. Modal's SDK cannot kill an exec'd process through its handle, so the remote command records its pid in a pidfile first (``echo $$`` then ``exec`` keeps the pid across the swap) and the interrupt path issues ``kill`` via a second exec. ``TERM`` is forced to ``xterm-256color`` for the same reason as the lakebox launcher: native harnesses spawn tmux, which refuses to start under a dumb/unset TERM. :param sandbox_id: Target sandbox. :param command: Shell command to execute remotely, e.g. ``"omnigent host --server https://…"``. :returns: The remote command's exit code. :raises KeyboardInterrupt: Re-raised after killing the remote process when the user detaches with Ctrl-C. """ handle = self._resolve(sandbox_id) remote = f"echo $$ > {_FOREGROUND_PIDFILE} && TERM=xterm-256color exec {command}" process = handle.exec("bash", "-lc", remote, pty=True) try: for line in process.stdout: click.echo(line, nl=False) return process.wait() except KeyboardInterrupt: click.echo("\n → detaching; stopping the remote process") handle.exec("bash", "-c", f"kill $(cat {_FOREGROUND_PIDFILE}) 2>/dev/null").wait() raise def wheel_install_command(self, remote_tgz_path: str) -> str: """ Remote command that overlays the shipped wheels onto the prebaked host image — see :func:`~omnigent.onboarding.sandboxes.base.host_image_wheel_install_command` for the flag rationale. :param remote_tgz_path: Sandbox path of the shipped tarball, e.g. ``"/tmp/oa-wheels.tgz"``. :returns: Shell command string for :meth:`run`. """ return host_image_wheel_install_command(remote_tgz_path)