Files
2026-07-13 13:12:00 +08:00

2200 lines
92 KiB
Python

"""Server-launched sandbox hosts for ``host_type="managed"`` sessions.
The external host flow has a human run ``omnigent host`` on their own
machine. The managed flow replaces the human: when a session is created
with ``host_type="managed"``, the server provisions a cloud sandbox,
starts ``omnigent host`` inside it, and waits for that host to
register — after which the session rides the exact same host-launch
machinery an external host uses (binding token, ``host.launch_runner``
frame, runner tunnel).
The host's identity is DURABLE while its sandbox is not: the ``hosts``
row carries the managed columns (launch-token digest + expiry,
provider, sandbox id), and a relaunch overwrites them in place — a new
sandbox generation under the same ``host_id``, so session bindings
survive a sandbox dying at the provider's lifetime cap.
The sandbox host authenticates back with a dedicated launch token the
server mints per launch (see
:meth:`omnigent.stores.host_store.HostStore.register_managed_host` and
the managed-token branch in
:mod:`omnigent.server.routes.host_tunnel`) — the user's own
credentials never enter the sandbox.
How a deployment supplies the sandbox backend (two paths, one seam —
:class:`ManagedSandboxConfig` carries a launcher FACTORY, so embedding
deployments inject custom launchers the same way they inject custom
stores into ``create_app``):
1. **Server YAML** (OSS / self-hosted): :func:`parse_sandbox_config`
builds the config from the ``sandbox:`` section
(``omnigent server -c`` / ``OMNIGENT_CONFIG`` /
``<data_dir>/config.yaml``)::
sandbox:
provider: modal # lakebox|modal|daytona|boxlite|cwsandbox|islo|e2b|openshell
server_url: https://omnigent.example.com
modal: # optional block
image: docker.io/me/omnigent-host:latest # default: official image
secrets: [omnigent-llm] # Modal secrets injected as sandbox env
# (harness LLM keys, gateway URLs)
boxlite: # optional block (provider: boxlite)
image: docker.io/me/omnigent-host:latest # shared; default: official
env: [OPENAI_API_KEY, GIT_TOKEN] # shared; SERVER env var NAMES
# exactly one mode (mutually exclusive):
cloud: {endpoint: https://boxlite.example.com:8100} # CLOUD; key: BOXLITE_API_KEY env
# local: {home_dir: /data/boxlite, registry: {...}} # LOCAL (default if omitted)
daytona: # optional block (provider: daytona)
image: docker.io/me/omnigent-host:latest # default: official image
env: [OPENAI_API_KEY, GIT_TOKEN] # SERVER env var NAMES whose
# values are injected as
# sandbox env
islo: # optional block (provider: islo)
image: docker.io/me/omnigent-host:latest # default: official image
env: [OPENAI_API_KEY, GIT_TOKEN] # SERVER env var NAMES injected
# as sandbox env
base_url: https://api.islo.dev # optional API override
gateway_profile: default # optional Islo gateway profile
snapshot_name: warm-host # optional Islo snapshot name
workdir: /root/workspace # optional sandbox workdir
vcpus: 2
memory_mb: 4096
disk_gb: 20
openshell: # optional block (provider: openshell)
image: docker.io/me/omnigent-host:latest # default: official image
env: [OPENAI_API_KEY, GIT_TOKEN] # SERVER env var NAMES injected
# as sandbox env
cluster: my-gateway # optional OpenShell gateway name
The image defaults to the official prebaked host image
(``ghcr.io/omnigent-ai/omnigent-host:latest``; see
:data:`omnigent.onboarding.sandboxes.base.DEFAULT_HOST_IMAGE` and
the per-provider env overrides), so ``provider`` + ``server_url``
is a complete config. Provider credentials are NOT in this file
(12-factor): the Modal launcher reads ``MODAL_TOKEN_ID`` /
``MODAL_TOKEN_SECRET`` (or ``~/.modal.toml``) and the Daytona
launcher reads ``DAYTONA_API_KEY`` (plus optional
``DAYTONA_API_URL`` / ``DAYTONA_TARGET``), and the Islo launcher
reads ``ISLO_API_KEY`` (plus optional ``ISLO_BASE_URL``) from the
server process environment. The OpenShell launcher needs no API key:
it connects to the gateway made active with ``openshell gateway
select`` (``$OPENSHELL_GATEWAY`` / ``~/.config/openshell/active_gateway``,
or ``sandbox.openshell.cluster``), so the server process needs
OpenShell gateway access. ``modal``, ``daytona``, ``cwsandbox``,
``islo``, and ``openshell`` have managed-launch support; ``lakebox``
parses but rejects at launch.
2. **Direct construction** (embedding deployments): build
:class:`ManagedSandboxConfig` with a custom
:class:`~omnigent.onboarding.sandboxes.base.SandboxLauncher`
factory and pass it to ``create_app(sandbox_config=…)``::
ManagedSandboxConfig(
server_url=public_url,
launcher_factory=lambda: MySandboxLauncher(...),
token_ttl_s=7 * 24 * 3600,
)
A managed-only launcher implements ``prepare`` / ``provision`` /
``run`` / ``terminate``; the CLI-bootstrap primitives default to
capability errors and need no overrides.
"""
from __future__ import annotations
import asyncio
import logging
import re
import secrets
import time
import uuid
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
import click
from fastapi import HTTPException
from omnigent.db.utils import now_epoch
from omnigent.stores.host_store import Host, HostStore
if TYPE_CHECKING:
from omnigent.onboarding.sandboxes import SandboxLauncher
_logger = logging.getLogger(__name__)
# Providers the YAML `sandbox:` section accepts. Parsing accepts all
# known providers so a deployment can stage config ahead of support
# landing, but only PROVIDERS_WITH_MANAGED_LAUNCH can actually serve a
# managed session today. (Deployments that construct
# ManagedSandboxConfig directly are not constrained by either set —
# their launcher factory IS the support.)
SUPPORTED_SANDBOX_PROVIDERS: frozenset[str] = frozenset(
{
"lakebox",
"modal",
"daytona",
"boxlite",
"cwsandbox",
"islo",
"e2b",
"openshell",
"kubernetes",
}
)
PROVIDERS_WITH_MANAGED_LAUNCH: frozenset[str] = frozenset(
{"modal", "daytona", "boxlite", "cwsandbox", "islo", "e2b", "openshell", "kubernetes"}
)
# How long a managed launch waits for the sandboxed host to register
# before declaring failure. The image is pre-baked (no pip install at
# boot), so a healthy launch registers in seconds; the budget covers a
# cold registry pull of the image on first use.
MANAGED_HOST_ONLINE_TIMEOUT_S = 120
_ONLINE_POLL_INTERVAL_S = 1.0
# Launch-token lifetime for the YAML modal path: Modal's 24h sandbox
# cap plus an hour of slack, so a live sandbox can always
# re-authenticate its tunnel across reconnects, while a token leaked
# from a long-dead sandbox cannot. Scoped to the token, not the host:
# the host row is durable, and a relaunch mints a fresh token + expiry.
# Deployments injecting their own launcher choose their own TTL on
# ManagedSandboxConfig.
MODAL_MANAGED_TOKEN_TTL_S = 25 * 3600
# Launch-token lifetime for the YAML daytona path. Daytona sandboxes
# have no platform lifetime cap (idle auto-stop is disabled at
# provision), so the bound is policy, not platform: 7 days keeps a
# long-lived sandbox re-authenticating across tunnel reconnects while
# still expiring tokens of sandboxes nobody deleted. A relaunch (or a
# session past 7 days going through the dead-host relaunch path) mints
# a fresh token.
DAYTONA_MANAGED_TOKEN_TTL_S = 7 * 24 * 3600
# Launch-token lifetime for the YAML boxlite path. Boxlite boxes have no
# platform lifetime cap and persist across restarts, so the bound is policy,
# not platform: 7 days mirrors Daytona — long enough for a live box to
# re-authenticate its tunnel across reconnects while still expiring tokens of
# boxes nobody removed. A relaunch mints a fresh token.
BOXLITE_MANAGED_TOKEN_TTL_S = 7 * 24 * 3600
# Launch-token lifetime for the YAML islo path. Islo sandboxes are
# deleted by managed-session teardown; use the same 7-day policy bound
# as Daytona for long-lived hosts and stale-token cleanup.
ISLO_MANAGED_TOKEN_TTL_S = 7 * 24 * 3600
# Launch-token lifetime for the YAML openshell path. OpenShell sandboxes
# run until deleted (no platform lifetime cap), so the bound is policy,
# not platform: the same 7-day window as Daytona/Islo keeps a long-lived
# sandbox re-authenticating across tunnel reconnects while still expiring
# tokens of sandboxes nobody deleted. A relaunch mints a fresh token.
OPENSHELL_MANAGED_TOKEN_TTL_S = 7 * 24 * 3600
# Launch-token lifetime for the YAML kubernetes path. Runner Pods have no
# platform lifetime cap (they run until deleted by managed-session teardown),
# so the bound is policy, not platform: the same 7-day window as
# Daytona/Islo/OpenShell keeps a long-lived host re-authenticating across tunnel
# reconnects while still expiring tokens of Pods nobody deleted. A relaunch
# mints a fresh token (and the per-Pod token Secret is replaced).
KUBERNETES_MANAGED_TOKEN_TTL_S = 7 * 24 * 3600
# The cwsandbox launch-token TTL is NOT a constant: CW Sandbox's lifetime is
# operator-overridable (OMNIGENT_CWSANDBOX_MAX_LIFETIME_S), so the TTL is
# derived from the resolved lifetime at parse time via
# cwsandbox.managed_token_ttl_s() — always above the cap, so a live sandbox
# can re-authenticate its tunnel across reconnects while a leaked token can't.
# Where the in-sandbox host process logs — named in launch-failure
# errors so an operator knows where to look inside the sandbox.
_HOST_LOG_PATH = "/tmp/omnigent-host.log"
# How long a message POST waits for an in-flight managed launch to
# settle before giving up (see ManagedLaunchTracker). Covers the full
# launch/wake pipeline ON TOP OF the host-registration wait
# (MANAGED_HOST_ONLINE_TIMEOUT_S): the provider's provision/resume call
# (StartSandbox has no fixed upper bound), the host-tunnel reconnect on
# this replica, and the runner spawn/connect. The 120s slack must cover
# all of those so a slow cold launch/wake doesn't time the parked message
# out before the background launch settles — otherwise the first
# post-dormancy turn is lost even though the wake later succeeds. The wait
# resolves as soon as the launch settles, so this bound only bites a
# genuinely slow launch.
MANAGED_LAUNCH_RENDEZVOUS_TIMEOUT_S = MANAGED_HOST_ONLINE_TIMEOUT_S + 120
# Session label recording the repository-URL workspace a managed
# session was created with (the raw ``<url>[#<branch>]`` request
# value). ``conversations.workspace`` is overwritten with the CLONED
# path at bind time, so this label is what a sandbox RELAUNCH parses
# to re-clone the repository into the fresh generation's workspace.
MANAGED_REPO_LABEL_KEY = "omnigent.sandbox.repo"
@dataclass
class ManagedLaunch:
"""
One session's in-flight (or failed) managed-host launch.
Created by :meth:`ManagedLaunchTracker.begin` when
``POST /v1/sessions`` schedules the background launch, and settled
by the background task via :meth:`ManagedLaunchTracker.finish` /
:meth:`ManagedLaunchTracker.fail`.
:param settled: Set once the launch reaches a terminal state —
either success (host bound, runner launched) or failure.
Waiters (a message POST racing the provision) block on this.
:param error: Failure detail once settled unsuccessfully, e.g.
``"managed sandbox launch failed: …"``. ``None`` while
in flight and on success.
"""
settled: asyncio.Event
error: str | None = None
class ManagedLaunchTracker:
"""
In-memory index of managed-host launches keyed by session id.
``POST /v1/sessions`` with ``host_type="managed"`` returns before
the sandbox exists; this tracker is how the rest of the server
observes that window. A message POST that arrives mid-provision
waits on the session's :class:`ManagedLaunch` instead of failing
with "no runner bound"; a launch failure is recorded here so the
waiting POST (and any later one) reports the real reason.
Successful launches are removed on settle — from then on the
session looks like any host-bound session. Failed launches are
retained (the session row never got a host, so the recorded error
is the only trace of why) until the process restarts or a new
launch for the same session begins.
"""
def __init__(self) -> None:
"""Initialize the empty session-id → launch index."""
self._by_session: dict[str, ManagedLaunch] = {}
def begin(self, session_id: str) -> None:
"""
Register a new in-flight launch for *session_id*.
Replaces any prior entry (e.g. a retained failure from an
earlier attempt).
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
"""
self._by_session[session_id] = ManagedLaunch(settled=asyncio.Event())
def get(self, session_id: str) -> ManagedLaunch | None:
"""
Look up the launch state for *session_id*.
:param session_id: Session/conversation identifier.
:returns: The launch entry, or ``None`` when no managed launch
is in flight or recorded as failed for this session.
"""
return self._by_session.get(session_id)
def finish(self, session_id: str) -> None:
"""
Settle *session_id*'s launch as successful and forget it.
Waiters holding the entry observe ``settled`` with
``error is None``; later readers find no entry and take the
normal host-bound paths.
:param session_id: Session/conversation identifier.
"""
entry = self._by_session.pop(session_id, None)
if entry is not None:
entry.settled.set()
def fail(self, session_id: str, error: str) -> None:
"""
Settle *session_id*'s launch as failed, retaining the reason.
:param session_id: Session/conversation identifier.
:param error: Human-readable failure detail, e.g.
``"managed sandbox launch failed: spend limit reached"``.
"""
entry = self._by_session.get(session_id)
if entry is None:
return
entry.error = error
entry.settled.set()
@dataclass
class ManagedSandboxConfig:
"""
Everything the managed-host flow needs from a deployment.
Built by :func:`parse_sandbox_config` from the server YAML, or
constructed directly by embedding deployments to inject a custom
launcher (see the module docstring).
:param server_url: Public URL of THIS server that the sandboxed
host dials back to, e.g. ``"https://omnigent.example.com"``
(no trailing slash). Explicit — the server cannot reliably
infer its own public URL behind proxies.
:param launcher_factory: Zero-argument factory producing the
:class:`~omnigent.onboarding.sandboxes.base.SandboxLauncher`
each launch uses, e.g.
``lambda: ModalSandboxLauncher(image=…)``. Called per launch
(launchers may cache provider handles internally). May raise
``HTTPException`` to report an unusable backend — the YAML
path uses this for providers without managed support.
:param token_ttl_s: Launch-token lifetime in seconds, e.g.
``90000`` (25h) for Modal. Must comfortably exceed the
provider's maximum sandbox lifetime so a live sandbox can
always re-authenticate its tunnel across reconnects.
:param managed_launch_supported: Whether ``launcher_factory`` can
actually serve a managed launch. The YAML path sets this from
:data:`PROVIDERS_WITH_MANAGED_LAUNCH` — staged providers
(``lakebox``) parse but get ``False``, since their factory
rejects at launch. Defaults to ``True`` for
directly-constructed configs (an embedding deployment's
custom factory IS the support). Drives the unauthenticated
``managed_sandboxes_enabled`` capability flag on
``GET /v1/info``, which gates the web UI's sandbox option.
:param provider: Short provider name surfaced to the web UI so the
new-session sandbox option can be labeled per provider (e.g.
``"modal"`` → "Modal Sandbox", ``"lakebox"`` → "Databricks
Sandbox"). The YAML path sets it from the parsed
``sandbox.provider``. ``None`` for directly-constructed
embedding configs that don't name a provider — the UI then
falls back to the generic "New Sandbox" label. Exposed (when
managed launch is supported) on the unauthenticated
``GET /v1/info`` as ``sandbox_provider``.
"""
server_url: str
launcher_factory: Callable[[], SandboxLauncher]
token_ttl_s: int
managed_launch_supported: bool = True
provider: str | None = None
@dataclass
class ManagedHostLaunch:
"""
Result of a successful managed host launch.
:param host_id: The registered host's identifier, e.g.
``"host_a1b2c3d4..."`` — feed this to the same launch-runner
path an external ``host_id`` takes.
:param workspace: Absolute workspace path created inside the
sandbox, e.g. ``"/root/workspace"`` — or the cloned repository
directory (e.g. ``"/root/workspace/myrepo"``) when the session
requested a repository-URL workspace.
"""
host_id: str
workspace: str
@dataclass
class RepoWorkspace:
"""
Parsed repository-URL workspace for a managed session.
A managed create's ``workspace`` is a git repository URL with an
optional ``#<branch>`` fragment (Docker build-context style): the
URL fully describes what the server materializes inside the
sandbox. Built by :func:`parse_repo_workspace` — construct via the
parser, not directly, so every field has been validated.
:param url: The clone URL with any fragment stripped, e.g.
``"https://github.com/org/repo.git"`` or
``"git@github.com:org/repo.git"``.
:param branch: Branch to clone (``--branch … --single-branch``),
e.g. ``"release-1.2"``, or ``None`` for the default branch.
:param repo_name: Directory name the clone lands in under the
sandbox workspace, derived from the URL's last path segment
with ``.git`` stripped, e.g. ``"repo"``.
"""
url: str
branch: str | None
repo_name: str
# A full 40-hex object id — rejected as a clone fragment: cloning a
# commit lands the agent on a detached HEAD it cannot push from.
_COMMIT_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
# Directory names a repo URL may resolve to. Conservative on purpose:
# the name is interpolated into an in-sandbox shell path.
_REPO_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
# Characters git forbids in ref names (plus ``#``, which can never
# reach the fragment since the workspace splits on its FIRST ``#`` —
# a second ``#`` means the branch itself contains one, which the
# fragment form does not support).
_BRANCH_FORBIDDEN_CHARS = set(" \t~^:?*[\\#")
def is_repo_workspace(workspace: str) -> bool:
"""
Return whether *workspace* is a repository-URL workspace.
Used by the create-session schema to tell the managed form (a git
URL) apart from the external form (an absolute host path) without
fully parsing it.
:param workspace: The raw request workspace, e.g.
``"https://github.com/org/repo"`` or ``"/Users/me/repo"``.
:returns: ``True`` for the ``https://`` / ``git@`` URL forms.
"""
return workspace.startswith(("https://", "git@"))
def _validate_clone_branch(fragment: str) -> str:
"""
Validate a ``#<branch>`` fragment as a clonable branch name.
:param fragment: The fragment text after the first ``#``, e.g.
``"release-1.2"``.
:returns: The validated branch name, unchanged.
:raises ValueError: When the fragment is empty, is a commit SHA
(detached HEAD — pin commits via git worktree options
instead), or violates git ref-name rules.
"""
if not fragment:
raise ValueError("the '#' fragment must name a branch, e.g. '#main'")
if _COMMIT_SHA_RE.fullmatch(fragment):
raise ValueError(
"the '#' fragment must be a branch, not a commit SHA — a commit "
"checkout would leave the agent on a detached HEAD it cannot push"
)
if (
any(c in _BRANCH_FORBIDDEN_CHARS or ord(c) < 0x20 for c in fragment)
or fragment.startswith(("-", "/"))
or fragment.endswith(("/", "."))
or ".." in fragment
or "@{" in fragment
):
raise ValueError(f"'{fragment}' is not a valid git branch name")
return fragment
def _derive_repo_name(url: str) -> str:
"""
Derive the clone directory name from a repository URL.
:param url: The fragment-stripped clone URL, e.g.
``"https://github.com/org/repo.git"``.
:returns: The last path segment with ``.git`` stripped, e.g.
``"repo"``.
:raises ValueError: When no usable name can be derived (empty
path, or a name that is not filesystem-safe).
"""
last = url.rstrip("/").split("/")[-1]
# scp-style URLs with a single-segment path ("git@host:repo.git")
# have no "/" after the colon — take what follows it.
if ":" in last:
last = last.rsplit(":", 1)[-1]
name = last[: -len(".git")] if last.endswith(".git") else last
if not name or name in (".", "..") or not _REPO_NAME_RE.fullmatch(name):
raise ValueError(
f"could not derive a repository directory name from '{url}' — "
"the URL must end in the repository name, e.g. "
"'https://github.com/org/repo'"
)
return name
def parse_repo_workspace(workspace: str) -> RepoWorkspace:
"""
Parse and validate a managed session's repository-URL workspace.
Grammar (Docker build-context style)::
<repo>[#<branch>]
<repo> := https://<host>/<path> | git@<host>:<path>
The fragment splits on the FIRST ``#``; branches containing ``#``
are not supported in this form. Fails loud on anything malformed
so a bad workspace 422s at validation instead of surfacing as a
mid-provision clone error.
:param workspace: The raw request workspace, e.g.
``"https://github.com/org/repo#release-1.2"``.
:returns: The parsed, validated :class:`RepoWorkspace`.
:raises ValueError: When the URL or branch fragment is malformed.
"""
url, sep, fragment = workspace.partition("#")
if any(ch.isspace() for ch in workspace):
raise ValueError("a repository workspace must not contain whitespace")
if url.startswith("https://"):
host, slash, path = url[len("https://") :].partition("/")
if not host or not slash or not path.strip("/"):
raise ValueError(
f"'{url}' is not a usable https repository URL — expected "
"'https://<host>/<org>/<repo>'"
)
elif url.startswith("git@"):
host, colon, path = url[len("git@") :].partition(":")
if not host or not colon or not path.strip("/"):
raise ValueError(
f"'{url}' is not a usable ssh repository URL — expected 'git@<host>:<org>/<repo>'"
)
else:
raise ValueError(
f"'{url}' is not a supported repository URL — use "
"'https://<host>/<org>/<repo>' or 'git@<host>:<org>/<repo>'"
)
branch = _validate_clone_branch(fragment) if sep else None
return RepoWorkspace(url=url, branch=branch, repo_name=_derive_repo_name(url))
def _modal_launcher_factory(
image: str | None,
secrets: list[str] | None,
) -> Callable[[], SandboxLauncher]:
"""
Build the launcher factory for the YAML ``provider: modal`` path.
:param image: Registry image reference with omnigent pre-installed,
e.g. ``"docker.io/me/omnigent-host:latest"``, or ``None`` to
use the official prebaked host image (env-overridable; see
:func:`omnigent.onboarding.sandboxes.modal._build_sandbox_image`).
:param secrets: Modal secret names whose env vars (harness LLM
credentials, gateway URLs) are injected into every sandbox,
e.g. ``["omnigent-llm"]``, or ``None`` to resolve from the
launcher's env-var fallback / inject nothing.
:returns: A factory producing parameterized Modal launchers.
"""
def _build() -> SandboxLauncher:
"""Construct the Modal launcher (lazy SDK import inside)."""
from omnigent.onboarding.sandboxes.modal import ModalSandboxLauncher
return ModalSandboxLauncher(image=image, secrets=secrets)
return _build
def _unsupported_launcher_factory(provider: str) -> Callable[[], SandboxLauncher]:
"""
Build a factory that rejects launch for a not-yet-supported provider.
Lets a deployment stage ``sandbox:`` config for a provider before
managed-launch support lands: parsing succeeds, and the clear 400
only surfaces if a managed session is actually requested.
:param provider: The configured provider name, e.g. ``"daytona"``.
:returns: A factory that raises ``HTTPException`` 400 when called.
"""
def _reject() -> SandboxLauncher:
"""Reject the launch with the provider named."""
raise HTTPException(
status_code=400,
detail=(
f"managed hosts are not yet supported for the "
f"'{provider}' sandbox provider — only "
f"{', '.join(sorted(PROVIDERS_WITH_MANAGED_LAUNCH))} is implemented"
),
)
return _reject
def parse_sandbox_config(raw: object) -> ManagedSandboxConfig | None:
"""
Parse and validate the server config's ``sandbox:`` section.
Fails loud on malformed config (an operator typo should stop server
startup, not surface as a runtime 502 on the first managed session).
:param raw: The raw ``sandbox`` value from the server config YAML,
e.g. ``{"provider": "modal", "server_url": "https://…",
"modal": {"image": "docker.io/me/omnigent-host:latest"}}``.
``None`` when the section is absent.
:returns: The parsed config, or ``None`` when *raw* is ``None``
(managed hosts not configured).
:raises ValueError: When the section is present but malformed.
"""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError("server config 'sandbox' must be a mapping")
provider = raw.get("provider")
if provider not in SUPPORTED_SANDBOX_PROVIDERS:
supported = ", ".join(sorted(SUPPORTED_SANDBOX_PROVIDERS))
raise ValueError(
f"server config 'sandbox.provider' must be one of: {supported} (got {provider!r})"
)
server_url = raw.get("server_url")
if not isinstance(server_url, str) or not server_url.strip():
raise ValueError(
"server config 'sandbox.server_url' is required — the public URL "
"of this server that sandboxed hosts connect back to"
)
if provider == "modal":
launcher_factory = _modal_launcher_factory(
_parse_modal_image(raw), _parse_modal_secrets(raw)
)
token_ttl_s = MODAL_MANAGED_TOKEN_TTL_S
elif provider == "daytona":
launcher_factory = _daytona_launcher_factory(
_parse_daytona_image(raw), _parse_daytona_env(raw)
)
token_ttl_s = DAYTONA_MANAGED_TOKEN_TTL_S
elif provider == "boxlite":
section = _boxlite_section(raw)
_reject_unknown_boxlite_keys(
section, {"image", "env", "local", "cloud"}, "sandbox.boxlite"
)
endpoint, home_dir, registry = _parse_boxlite_mode(section)
launcher_factory = _boxlite_launcher_factory(
endpoint,
_parse_boxlite_image(section),
_parse_boxlite_env(section),
home_dir,
registry,
)
token_ttl_s = BOXLITE_MANAGED_TOKEN_TTL_S
elif provider == "cwsandbox":
from omnigent.onboarding.sandboxes.cwsandbox import managed_token_ttl_s
launcher_factory = _cwsandbox_launcher_factory(
_parse_cwsandbox_image(raw), _parse_cwsandbox_env(raw)
)
# Derived from OMNIGENT_CWSANDBOX_MAX_LIFETIME_S so the token always
# outlives the (operator-overridable) sandbox lifetime.
token_ttl_s = managed_token_ttl_s()
elif provider == "islo":
launcher_factory = _islo_launcher_factory(
image=_parse_provider_image(raw, "islo"),
env=_parse_provider_env(raw, "islo"),
base_url=_parse_provider_string(raw, "islo", "base_url"),
gateway_profile=_parse_provider_string(raw, "islo", "gateway_profile"),
snapshot_name=_parse_provider_string(raw, "islo", "snapshot_name"),
workdir=_parse_provider_string(raw, "islo", "workdir"),
vcpus=_parse_provider_positive_int(raw, "islo", "vcpus"),
memory_mb=_parse_provider_positive_int(raw, "islo", "memory_mb"),
disk_gb=_parse_provider_positive_int(raw, "islo", "disk_gb"),
)
token_ttl_s = ISLO_MANAGED_TOKEN_TTL_S
elif provider == "e2b":
from omnigent.onboarding.sandboxes.e2b import managed_token_ttl_s
launcher_factory = _e2b_launcher_factory(
_parse_e2b_template(raw), _parse_provider_env(raw, "e2b")
)
# Derived from OMNIGENT_E2B_MAX_LIFETIME_S so the token always
# outlives the (operator-overridable) sandbox lifetime — mirrors
# the cwsandbox path.
token_ttl_s = managed_token_ttl_s()
elif provider == "openshell":
launcher_factory = _openshell_launcher_factory(
image=_parse_provider_image(raw, "openshell"),
env=_parse_provider_env(raw, "openshell"),
cluster=_parse_provider_string(raw, "openshell", "cluster"),
)
token_ttl_s = OPENSHELL_MANAGED_TOKEN_TTL_S
elif provider == "kubernetes":
launcher_factory = _kubernetes_launcher_factory(
image=_parse_provider_image(raw, "kubernetes"),
env=_parse_provider_env(raw, "kubernetes"),
namespace=_parse_provider_string(raw, "kubernetes", "namespace"),
secret_name=_parse_provider_string(raw, "kubernetes", "secret_name"),
service_account=_parse_provider_string(raw, "kubernetes", "service_account"),
node_selector=_parse_provider_str_mapping(raw, "kubernetes", "node_selector"),
kubeconfig=_parse_provider_string(raw, "kubernetes", "kubeconfig"),
in_cluster=_parse_provider_bool(raw, "kubernetes", "in_cluster"),
resources=_parse_kubernetes_resources(raw),
)
token_ttl_s = KUBERNETES_MANAGED_TOKEN_TTL_S
else:
launcher_factory = _unsupported_launcher_factory(provider)
# Never consulted (the factory rejects before any token is
# minted); the conservative modal TTL keeps the field total.
token_ttl_s = MODAL_MANAGED_TOKEN_TTL_S
return ManagedSandboxConfig(
server_url=server_url.strip().rstrip("/"),
launcher_factory=launcher_factory,
token_ttl_s=token_ttl_s,
managed_launch_supported=provider in PROVIDERS_WITH_MANAGED_LAUNCH,
provider=provider,
)
def _parse_modal_image(raw: dict[str, object]) -> str | None:
"""
Extract and validate the modal image from the raw ``sandbox`` dict.
The ``modal`` section and its ``image`` field are OPTIONAL — when
absent, sandboxes boot from the official prebaked host image
(env-overridable; see
:func:`omnigent.onboarding.sandboxes.modal._build_sandbox_image`).
A present-but-malformed value still fails loud.
:param raw: The raw ``sandbox`` mapping (provider already known to
be ``"modal"``).
:returns: The validated image reference, or ``None`` to use the
official default.
:raises ValueError: When ``sandbox.modal`` is present but not a
mapping, or ``sandbox.modal.image`` is present but not a
non-empty string.
"""
modal_raw = raw.get("modal")
if modal_raw is None:
return None
if not isinstance(modal_raw, dict):
raise ValueError("server config 'sandbox.modal' must be a mapping")
image = modal_raw.get("image")
if image is None:
return None
if not isinstance(image, str) or not image.strip():
raise ValueError(
"server config 'sandbox.modal.image' must be a registry image "
"reference with omnigent pre-installed, e.g. "
"'docker.io/me/omnigent-host:latest' (omit it to use the "
"official image)"
)
return image.strip()
def _parse_modal_secrets(raw: dict[str, object]) -> list[str] | None:
"""
Extract and validate the modal secret names from the ``sandbox`` dict.
``sandbox.modal.secrets`` names the Modal secrets whose env vars
(harness LLM credentials, gateway base URLs) are injected into
every managed sandbox. OPTIONAL — absent means the launcher's
env-var fallback applies (or nothing is injected). A
present-but-malformed value fails loud.
:param raw: The raw ``sandbox`` mapping (provider already known to
be ``"modal"``).
:returns: The validated secret names, e.g. ``["omnigent-llm"]``,
or ``None`` when not configured.
:raises ValueError: When ``sandbox.modal`` is present but not a
mapping, or ``sandbox.modal.secrets`` is present but not a
list of non-empty strings.
"""
modal_raw = raw.get("modal")
if modal_raw is None:
return None
if not isinstance(modal_raw, dict):
raise ValueError("server config 'sandbox.modal' must be a mapping")
secrets = modal_raw.get("secrets")
if secrets is None:
return None
if not isinstance(secrets, list) or not all(
isinstance(name, str) and name.strip() for name in secrets
):
raise ValueError(
"server config 'sandbox.modal.secrets' must be a list of Modal "
"secret names, e.g. ['omnigent-llm']"
)
return [name.strip() for name in secrets]
def _daytona_launcher_factory(
image: str | None,
env: list[str] | None,
) -> Callable[[], SandboxLauncher]:
"""
Build the launcher factory for the YAML ``provider: daytona`` path.
:param image: Registry image reference with omnigent pre-installed,
e.g. ``"docker.io/me/omnigent-host:latest"``, or ``None`` to
use the official prebaked host image (env-overridable; see
:class:`omnigent.onboarding.sandboxes.daytona.DaytonaSandboxLauncher`).
:param env: Names of server-process environment variables (harness
LLM credentials, gateway URLs, ``GIT_TOKEN``) injected into
every sandbox, e.g. ``["OPENAI_API_KEY", "GIT_TOKEN"]``, or
``None`` to resolve from the launcher's env-var fallback /
inject nothing.
:returns: A factory producing parameterized Daytona launchers.
"""
def _build() -> SandboxLauncher:
"""Construct the Daytona launcher (lazy SDK import inside)."""
from omnigent.onboarding.sandboxes.daytona import DaytonaSandboxLauncher
return DaytonaSandboxLauncher(image=image, env=env)
return _build
def _parse_daytona_image(raw: dict[str, object]) -> str | None:
"""
Extract and validate the daytona image from the ``sandbox`` dict.
The ``daytona`` section and its ``image`` field are OPTIONAL —
when absent, sandboxes boot from the official prebaked host image
(env-overridable; see
:mod:`omnigent.onboarding.sandboxes.daytona`). A
present-but-malformed value still fails loud.
:param raw: The raw ``sandbox`` mapping (provider already known to
be ``"daytona"``).
:returns: The validated image reference, or ``None`` to use the
official default.
:raises ValueError: When ``sandbox.daytona`` is present but not a
mapping, or ``sandbox.daytona.image`` is present but not a
non-empty string.
"""
daytona_raw = raw.get("daytona")
if daytona_raw is None:
return None
if not isinstance(daytona_raw, dict):
raise ValueError("server config 'sandbox.daytona' must be a mapping")
image = daytona_raw.get("image")
if image is None:
return None
if not isinstance(image, str) or not image.strip():
raise ValueError(
"server config 'sandbox.daytona.image' must be a registry image "
"reference with omnigent pre-installed, e.g. "
"'docker.io/me/omnigent-host:latest' (omit it to use the "
"official image)"
)
return image.strip()
def _parse_daytona_env(raw: dict[str, object]) -> list[str] | None:
"""
Extract and validate the daytona env names from the ``sandbox`` dict.
``sandbox.daytona.env`` names the SERVER-process environment
variables whose values (harness LLM credentials, gateway base
URLs, ``GIT_TOKEN``) are injected into every managed sandbox —
names only, so secret values never live in the config file.
OPTIONAL — absent means the launcher's env-var fallback applies
(or nothing is injected). A present-but-malformed value fails
loud.
:param raw: The raw ``sandbox`` mapping (provider already known to
be ``"daytona"``).
:returns: The validated env var names, e.g.
``["OPENAI_API_KEY", "GIT_TOKEN"]``, or ``None`` when not
configured.
:raises ValueError: When ``sandbox.daytona`` is present but not a
mapping, or ``sandbox.daytona.env`` is present but not a list
of non-empty strings.
"""
daytona_raw = raw.get("daytona")
if daytona_raw is None:
return None
if not isinstance(daytona_raw, dict):
raise ValueError("server config 'sandbox.daytona' must be a mapping")
env = daytona_raw.get("env")
if env is None:
return None
if not isinstance(env, list) or not all(
isinstance(name, str) and name.strip() for name in env
):
raise ValueError(
"server config 'sandbox.daytona.env' must be a list of server "
"environment variable NAMES to inject, e.g. ['OPENAI_API_KEY', "
"'GIT_TOKEN']"
)
return [name.strip() for name in env]
def _boxlite_launcher_factory(
endpoint: str | None,
image: str | None,
env: list[str] | None,
home_dir: str | None,
registry: dict[str, object] | None,
) -> Callable[[], SandboxLauncher]:
"""
Build the launcher factory for the YAML ``provider: boxlite`` path.
:param endpoint: Remote ``boxlite serve`` URL (cloud mode), or ``None`` for
LOCAL mode — boxes run on the omnigent-server host as embedded micro-VMs
(no daemon, no ``boxlite serve``).
:param image: Registry image reference with omnigent pre-installed, or
``None`` to use the official prebaked host image (env-overridable; see
:class:`omnigent.onboarding.sandboxes.boxlite.BoxliteSandboxLauncher`).
:param env: Names of server-process environment variables (harness LLM
credentials, gateway URLs, ``GIT_TOKEN``) injected into every box, e.g.
``["OPENAI_API_KEY", "GIT_TOKEN"]``, or ``None``.
:param home_dir: LOCAL-mode boxlite data directory, or ``None`` for the
default (``~/.boxlite``).
:param registry: LOCAL-mode private-registry config for the host image
(``host`` + optional ``transport`` / ``skip_verify`` / ``*_env``
credential names), or ``None`` for anonymous pulls.
:returns: A factory producing parameterized boxlite launchers.
"""
def _build() -> SandboxLauncher:
"""Construct the boxlite launcher (lazy SDK import inside)."""
from omnigent.onboarding.sandboxes.boxlite import BoxliteSandboxLauncher
return BoxliteSandboxLauncher(
endpoint=endpoint, image=image, env=env, home_dir=home_dir, registry=registry
)
return _build
def _boxlite_section(raw: dict[str, object]) -> dict[str, object]:
"""
Return the validated ``sandbox.boxlite`` mapping (empty when absent).
:raises ValueError: When ``sandbox.boxlite`` is present but not a mapping.
"""
section = raw.get("boxlite")
if section is None:
return {}
if not isinstance(section, dict):
raise ValueError("server config 'sandbox.boxlite' must be a mapping")
return section
def _reject_unknown_boxlite_keys(mapping: dict[str, object], allowed: set[str], path: str) -> None:
"""
Fail loud on any key outside *allowed* — catches typos and misplaced keys
(e.g. ``endpoint`` at the section level instead of under ``cloud:``, or a
misspelled ``passwrod_env``) that would otherwise be silently ignored and
surface much later as a confusing runtime failure.
"""
unknown = sorted(set(mapping) - allowed)
if unknown:
raise ValueError(
f"server config '{path}' has unknown key(s): {', '.join(unknown)} "
f"(allowed: {', '.join(sorted(allowed))})"
)
def _parse_boxlite_mode(
section: dict[str, object],
) -> tuple[str | None, str | None, dict[str, object] | None]:
"""
Resolve the boxlite runtime MODE from the mutually-exclusive ``local`` /
``cloud`` sub-blocks and return the launcher's ``(endpoint, home_dir,
registry)``.
- ``cloud:`` present → CLOUD mode (a remote ``boxlite serve``).
``cloud.endpoint`` is required; the API key is read from
``BOXLITE_API_KEY`` in the server env (12-factor, not config).
- else → LOCAL mode (embedded micro-VMs on the server host). The optional
``local:`` block carries ``home_dir`` / ``registry``.
Setting both ``local`` and ``cloud`` is rejected — they are two different
configurations and a session runs in exactly one mode.
:returns: ``(endpoint, home_dir, registry)`` — only *endpoint* (cloud) or
the *home_dir*/*registry* pair (local) is ever populated.
:raises ValueError: On a malformed or ambiguous mode config.
"""
# Test for KEY PRESENCE, not value: a bare `cloud:`/`local:` YAML key
# parses to None, which must be rejected as malformed — not silently
# fall through to LOCAL mode (a `cloud:` typo would then run locally).
local_present = "local" in section
cloud_present = "cloud" in section
local_block = section.get("local")
cloud_block = section.get("cloud")
if local_present and cloud_present:
raise ValueError(
"server config 'sandbox.boxlite' must set at most one of 'local' or "
"'cloud' — the two modes are mutually exclusive"
)
if cloud_present:
if not isinstance(cloud_block, dict):
raise ValueError("server config 'sandbox.boxlite.cloud' must be a mapping")
_reject_unknown_boxlite_keys(cloud_block, {"endpoint"}, "sandbox.boxlite.cloud")
endpoint = cloud_block.get("endpoint")
if not isinstance(endpoint, str) or not endpoint.strip():
raise ValueError(
"server config 'sandbox.boxlite.cloud.endpoint' is required — the "
"boxlite REST URL, e.g. 'https://boxlite.example.com:8100'"
)
return endpoint.strip(), None, None
# Local mode (the default when neither block is present).
if not local_present:
return None, None, None
if not isinstance(local_block, dict):
raise ValueError("server config 'sandbox.boxlite.local' must be a mapping")
_reject_unknown_boxlite_keys(local_block, {"home_dir", "registry"}, "sandbox.boxlite.local")
return None, _parse_boxlite_home_dir(local_block), _parse_boxlite_registry(local_block)
def _parse_boxlite_image(section: dict[str, object]) -> str | None:
"""
Extract the optional shared ``sandbox.boxlite.image`` (default: official
host image). Shared by both modes.
:returns: The validated image reference, or ``None`` to use the default.
:raises ValueError: When present but not a non-empty string.
"""
image = section.get("image")
if image is None:
return None
if not isinstance(image, str) or not image.strip():
raise ValueError(
"server config 'sandbox.boxlite.image' must be a registry image "
"reference with omnigent pre-installed, e.g. "
"'docker.io/me/omnigent-host:latest' (omit it to use the official image)"
)
return image.strip()
def _parse_boxlite_env(section: dict[str, object]) -> list[str] | None:
"""
Extract the optional shared ``sandbox.boxlite.env`` — SERVER-process
environment variable NAMES whose values are injected into every box (names
only, so secret values never live in the config file). Shared by both modes.
:returns: The validated env var names, or ``None`` when not configured.
:raises ValueError: When present but not a list of non-empty strings.
"""
env = section.get("env")
if env is None:
return None
if not isinstance(env, list) or not all(
isinstance(name, str) and name.strip() for name in env
):
raise ValueError(
"server config 'sandbox.boxlite.env' must be a list of server "
"environment variable NAMES to inject, e.g. ['OPENAI_API_KEY', 'GIT_TOKEN']"
)
return [name.strip() for name in env]
def _parse_boxlite_home_dir(local: dict[str, object]) -> str | None:
"""
Extract the optional ``sandbox.boxlite.local.home_dir`` (boxlite data dir).
:returns: The validated path, or ``None`` to use boxlite's default.
:raises ValueError: When present but not a non-empty string.
"""
home_dir = local.get("home_dir")
if home_dir is None:
return None
if not isinstance(home_dir, str) or not home_dir.strip():
raise ValueError(
"server config 'sandbox.boxlite.local.home_dir' must be a non-empty path string"
)
return home_dir.strip()
def _parse_boxlite_registry(local: dict[str, object]) -> dict[str, object] | None:
"""
Extract the optional ``sandbox.boxlite.local.registry`` block — private-
registry config for pulling the host image in LOCAL mode.
Shape: ``host`` (required) plus optional ``transport`` / ``skip_verify`` and
the credential-NAME keys ``username_env`` / ``password_env`` / ``token_env``
(which name server env vars holding the values — 12-factor, so secrets never
live in the config file).
:returns: The validated registry mapping, or ``None`` when not configured.
:raises ValueError: When present but malformed.
"""
registry = local.get("registry")
if registry is None:
return None
if not isinstance(registry, dict):
raise ValueError("server config 'sandbox.boxlite.local.registry' must be a mapping")
_reject_unknown_boxlite_keys(
registry,
{"host", "transport", "skip_verify", "username_env", "password_env", "token_env"},
"sandbox.boxlite.local.registry",
)
host = registry.get("host")
if not isinstance(host, str) or not host.strip():
raise ValueError(
"server config 'sandbox.boxlite.local.registry.host' is required — the "
"registry hostname, e.g. 'ghcr.io'"
)
out: dict[str, object] = {"host": host.strip()}
for key in ("transport", "username_env", "password_env", "token_env"):
value = registry.get(key)
if value is None:
continue
if not isinstance(value, str) or not value.strip():
raise ValueError(
f"server config 'sandbox.boxlite.local.registry.{key}' must be a non-empty string"
)
out[key] = value.strip()
skip_verify = registry.get("skip_verify")
if skip_verify is not None:
if not isinstance(skip_verify, bool):
raise ValueError(
"server config 'sandbox.boxlite.local.registry.skip_verify' must be a boolean"
)
out["skip_verify"] = skip_verify
if "token_env" in out and ("username_env" in out or "password_env" in out):
raise ValueError(
"server config 'sandbox.boxlite.local.registry': token_env is mutually "
"exclusive with username_env/password_env — boxlite uses the bearer token "
"and silently ignores basic auth, so set exactly one auth method"
)
return out
def _cwsandbox_launcher_factory(
image: str | None,
env: list[str] | None,
) -> Callable[[], SandboxLauncher]:
"""Build the launcher factory for the YAML ``provider: cwsandbox`` path."""
def _build() -> SandboxLauncher:
from omnigent.onboarding.sandboxes.cwsandbox import CWSandboxLauncher
return CWSandboxLauncher(image=image, env=env)
return _build
def _parse_cwsandbox_image(raw: dict[str, object]) -> str | None:
"""Extract and validate ``sandbox.cwsandbox.image`` (optional)."""
section = raw.get("cwsandbox")
if section is None:
return None
if not isinstance(section, dict):
raise ValueError("server config 'sandbox.cwsandbox' must be a mapping")
image = section.get("image")
if image is None:
return None
if not isinstance(image, str) or not image.strip():
raise ValueError(
"server config 'sandbox.cwsandbox.image' must be a registry image "
"reference with omnigent pre-installed (omit it to use the official image)"
)
return image.strip()
def _parse_cwsandbox_env(raw: dict[str, object]) -> list[str] | None:
"""Extract and validate ``sandbox.cwsandbox.env`` — server env var NAMES (optional)."""
section = raw.get("cwsandbox")
if section is None:
return None
if not isinstance(section, dict):
raise ValueError("server config 'sandbox.cwsandbox' must be a mapping")
env = section.get("env")
if env is None:
return None
if not isinstance(env, list) or not all(
isinstance(name, str) and name.strip() for name in env
):
raise ValueError(
"server config 'sandbox.cwsandbox.env' must be a list of server "
"environment variable NAMES to inject, e.g. ['ANTHROPIC_API_KEY', 'GIT_TOKEN']"
)
return [name.strip() for name in env]
def _e2b_launcher_factory(
template: str | None,
env: list[str] | None,
) -> Callable[[], SandboxLauncher]:
"""
Build the launcher factory for the YAML ``provider: e2b`` path.
:param template: E2B template NAME the Omnigent host image was built
into (``e2b template build``), or ``None`` to use the launcher's
env-var fallback / the default template. Unlike the other
providers' ``image`` field this is NOT a registry reference —
E2B boots from templates (see
:class:`omnigent.onboarding.sandboxes.e2b.E2BSandboxLauncher`).
:param env: Names of server-process environment variables (harness
LLM credentials, gateway URLs, ``GIT_TOKEN``) injected into
every sandbox, e.g. ``["OPENAI_API_KEY", "GIT_TOKEN"]``, or
``None`` to resolve from the launcher's env-var fallback /
inject nothing.
:returns: A factory producing parameterized E2B launchers.
"""
def _build() -> SandboxLauncher:
"""Construct the E2B launcher (lazy SDK import inside)."""
from omnigent.onboarding.sandboxes.e2b import E2BSandboxLauncher
return E2BSandboxLauncher(template=template, env=env)
return _build
def _parse_e2b_template(raw: dict[str, object]) -> str | None:
"""
Extract and validate the e2b template from the ``sandbox`` dict.
``sandbox.e2b.template`` names the pre-built E2B template the
Omnigent host image was built into — NOT a registry image reference
(the wording every other provider's ``image`` field uses), because
E2B cannot boot an arbitrary registry image. OPTIONAL — when absent,
the launcher resolves :data:`~omnigent.onboarding.sandboxes.e2b.TEMPLATE_ENV_VAR`
then the default template. A present-but-malformed value fails loud.
:param raw: The raw ``sandbox`` mapping (provider already known to
be ``"e2b"``).
:returns: The validated template name, or ``None`` to use the
launcher's fallback / default.
:raises ValueError: When ``sandbox.e2b`` is present but not a
mapping, or ``sandbox.e2b.template`` is present but not a
non-empty string.
"""
section = _parse_provider_section(raw, "e2b")
if section is None:
return None
template = section.get("template")
if template is None:
return None
if not isinstance(template, str) or not template.strip():
raise ValueError(
"server config 'sandbox.e2b.template' must be the NAME of a pre-built "
"E2B template the omnigent host image was built into (e.g. "
"'omnigent-host'; see deploy/e2b/README.md) — NOT a registry image "
"reference (omit it to use the default template)"
)
return template.strip()
def _islo_launcher_factory(
*,
image: str | None,
env: list[str] | None,
base_url: str | None,
gateway_profile: str | None,
snapshot_name: str | None,
workdir: str | None,
vcpus: int | None,
memory_mb: int | None,
disk_gb: int | None,
) -> Callable[[], SandboxLauncher]:
"""
Build the launcher factory for the YAML ``provider: islo`` path.
:param image: Registry image reference with omnigent pre-installed,
e.g. ``"docker.io/me/omnigent-host:latest"``, or ``None`` to
use the official prebaked host image (env-overridable; see
:class:`omnigent.onboarding.sandboxes.islo.IsloSandboxLauncher`).
:param env: Names of server-process environment variables injected
into every sandbox, e.g. ``["OPENAI_API_KEY", "GIT_TOKEN"]``,
or ``None`` to resolve from the launcher's env-var fallback /
inject nothing.
:param base_url: Optional Islo API base URL override.
:param gateway_profile: Optional Islo gateway profile name.
:param snapshot_name: Optional Islo snapshot name.
:param workdir: Optional sandbox working directory.
:param vcpus: Optional vCPU count.
:param memory_mb: Optional memory allocation in MiB.
:param disk_gb: Optional disk allocation in GiB.
:returns: A factory producing parameterized Islo launchers.
"""
def _build() -> SandboxLauncher:
"""Construct the Islo launcher."""
from omnigent.onboarding.sandboxes.islo import IsloSandboxLauncher
return IsloSandboxLauncher(
image=image,
env=env,
base_url=base_url,
gateway_profile=gateway_profile,
snapshot_name=snapshot_name,
workdir=workdir,
vcpus=vcpus,
memory_mb=memory_mb,
disk_gb=disk_gb,
)
return _build
def _openshell_launcher_factory(
*,
image: str | None,
env: list[str] | None,
cluster: str | None,
) -> Callable[[], SandboxLauncher]:
"""
Build the launcher factory for the YAML ``provider: openshell`` path.
:param image: Registry image reference with omnigent pre-installed,
e.g. ``"docker.io/me/omnigent-host:latest"``, or ``None`` to use
the official prebaked host image (env-overridable).
:param env: Names of server-process environment variables injected
into every sandbox, e.g. ``["OPENAI_API_KEY", "GIT_TOKEN"]``, or
``None`` to resolve from the launcher's env-var fallback.
:param cluster: OpenShell gateway name to connect to, or ``None`` to
use the active gateway (``$OPENSHELL_GATEWAY`` /
``~/.config/openshell/active_gateway``).
:returns: A factory producing parameterized OpenShell launchers.
"""
def _build() -> SandboxLauncher:
"""Construct the OpenShell launcher (lazy SDK import inside)."""
from omnigent.onboarding.sandboxes.openshell import OpenShellSandboxLauncher
return OpenShellSandboxLauncher(image=image, env=env, cluster=cluster)
return _build
def _parse_provider_section(raw: dict[str, object], provider: str) -> dict[str, object] | None:
"""
Extract a provider-specific optional config block.
:param raw: The raw ``sandbox`` mapping.
:param provider: Provider block name, e.g. ``"islo"``.
:returns: The provider mapping, or ``None`` when omitted.
:raises ValueError: When the block is present but not a mapping.
"""
section = raw.get(provider)
if section is None:
return None
if not isinstance(section, dict):
raise ValueError(f"server config 'sandbox.{provider}' must be a mapping")
return section
def _parse_provider_image(raw: dict[str, object], provider: str) -> str | None:
"""
Extract and validate a provider image from the raw ``sandbox`` dict.
:param raw: The raw ``sandbox`` mapping.
:param provider: Provider block name, e.g. ``"islo"``.
:returns: The validated image reference, or ``None`` to use the
official default.
:raises ValueError: When the provider block or image value is
malformed.
"""
section = _parse_provider_section(raw, provider)
if section is None:
return None
image = section.get("image")
if image is None:
return None
if not isinstance(image, str) or not image.strip():
raise ValueError(
f"server config 'sandbox.{provider}.image' must be a registry image "
"reference with omnigent pre-installed, e.g. "
"'docker.io/me/omnigent-host:latest' (omit it to use the "
"official image)"
)
return image.strip()
def _parse_provider_env(raw: dict[str, object], provider: str) -> list[str] | None:
"""
Extract and validate provider env passthrough names.
:param raw: The raw ``sandbox`` mapping.
:param provider: Provider block name, e.g. ``"islo"``.
:returns: Validated environment variable names, or ``None`` when
not configured.
:raises ValueError: When the provider block or env list is
malformed.
"""
section = _parse_provider_section(raw, provider)
if section is None:
return None
env = section.get("env")
if env is None:
return None
if not isinstance(env, list) or not all(
isinstance(name, str) and name.strip() for name in env
):
raise ValueError(
f"server config 'sandbox.{provider}.env' must be a list of server "
"environment variable NAMES to inject, e.g. ['OPENAI_API_KEY', "
"'GIT_TOKEN']"
)
return [name.strip() for name in env]
def _parse_provider_string(raw: dict[str, object], provider: str, key: str) -> str | None:
"""
Extract and validate an optional provider string field.
:param raw: The raw ``sandbox`` mapping.
:param provider: Provider block name, e.g. ``"islo"``.
:param key: Field name under the provider block.
:returns: The stripped string, or ``None`` when omitted.
:raises ValueError: When the field is present but not a non-empty
string.
"""
section = _parse_provider_section(raw, provider)
if section is None:
return None
value = section.get(key)
if value is None:
return None
if not isinstance(value, str) or not value.strip():
raise ValueError(f"server config 'sandbox.{provider}.{key}' must be a non-empty string")
return value.strip()
def _parse_provider_positive_int(raw: dict[str, object], provider: str, key: str) -> int | None:
"""
Extract and validate an optional positive integer provider field.
:param raw: The raw ``sandbox`` mapping.
:param provider: Provider block name, e.g. ``"islo"``.
:param key: Field name under the provider block.
:returns: The integer, or ``None`` when omitted.
:raises ValueError: When the field is present but is not a positive
integer.
"""
section = _parse_provider_section(raw, provider)
if section is None:
return None
value = section.get(key)
if value is None:
return None
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
raise ValueError(f"server config 'sandbox.{provider}.{key}' must be a positive integer")
return value
def _parse_provider_bool(raw: dict[str, object], provider: str, key: str) -> bool | None:
"""
Extract and validate an optional boolean provider field.
:param raw: The raw ``sandbox`` mapping.
:param provider: Provider block name, e.g. ``"kubernetes"``.
:param key: Field name under the provider block, e.g. ``"in_cluster"``.
:returns: The boolean, or ``None`` when omitted.
:raises ValueError: When the field is present but is not a real boolean (a
YAML ``"true"`` string or an int are rejected — a silently-coerced flag
would change the cluster-config source).
"""
section = _parse_provider_section(raw, provider)
if section is None:
return None
value = section.get(key)
if value is None:
return None
if not isinstance(value, bool):
raise ValueError(f"server config 'sandbox.{provider}.{key}' must be a boolean")
return value
def _parse_provider_str_mapping(
raw: dict[str, object], provider: str, key: str
) -> dict[str, str] | None:
"""
Extract and validate an optional provider string→string mapping field.
:param raw: The raw ``sandbox`` mapping.
:param provider: Provider block name, e.g. ``"kubernetes"``.
:param key: Field name under the provider block, e.g. ``"node_selector"``.
:returns: The validated mapping, or ``None`` when omitted.
:raises ValueError: When the field is present but not a mapping of non-empty
string keys to non-empty string values.
"""
section = _parse_provider_section(raw, provider)
if section is None:
return None
value = section.get(key)
if value is None:
return None
if not isinstance(value, dict) or not all(
isinstance(k, str) and k.strip() and isinstance(v, str) and v.strip()
for k, v in value.items()
):
raise ValueError(
f"server config 'sandbox.{provider}.{key}' must be a mapping of "
"non-empty string keys to non-empty string values, e.g. "
"{'disktype': 'ssd'}"
)
return {k.strip(): v.strip() for k, v in value.items()}
# RFC 1123 / Kubernetes identifier forms for parse-time validation of
# ``sandbox.kubernetes`` names (mirrored, fixed-by-spec, in the launcher for its
# env-var overrides — see omnigent.onboarding.sandboxes.kubernetes).
_DNS1123_LABEL_RE = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
_DNS1123_SUBDOMAIN_RE = re.compile(
r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$"
)
_K8S_LABEL_SEGMENT_RE = re.compile(r"^[A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?$")
# Kubernetes resource quantity, e.g. "500m", "2", "1Gi", "1.5" — a number with
# an optional binary/decimal suffix.
_K8S_QUANTITY_RE = re.compile(r"^\d+(\.\d+)?([eE][-+]?\d+)?[a-zA-Z]{0,2}i?$")
def _validate_dns1123_label(value: str | None, field: str) -> None:
"""Reject a ``sandbox.kubernetes.<field>`` that is not a DNS-1123 label."""
if value is None:
return
if len(value) > 63 or not _DNS1123_LABEL_RE.fullmatch(value):
raise ValueError(
f"server config 'sandbox.kubernetes.{field}' is not a valid "
f"Kubernetes name (RFC 1123 DNS label, max 63 chars): {value!r}"
)
def _validate_dns1123_subdomain(value: str | None, field: str) -> None:
"""Reject a ``sandbox.kubernetes.<field>`` that is not a DNS-1123 subdomain."""
if value is None:
return
if len(value) > 253 or not _DNS1123_SUBDOMAIN_RE.fullmatch(value):
raise ValueError(
f"server config 'sandbox.kubernetes.{field}' is not a valid "
f"Kubernetes name (RFC 1123 DNS subdomain): {value!r}"
)
def _validate_label_key(key: str) -> bool:
"""Return whether *key* is a valid Kubernetes label key (optional prefix)."""
prefix, slash, name = key.rpartition("/")
if slash and (not prefix or len(prefix) > 253 or not _DNS1123_SUBDOMAIN_RE.match(prefix)):
return False
return bool(name) and len(name) <= 63 and bool(_K8S_LABEL_SEGMENT_RE.match(name))
def _validate_kubernetes_identifiers(
namespace: str | None,
secret_name: str | None,
service_account: str | None,
node_selector: dict[str, str] | None,
) -> None:
"""
Validate the YAML ``sandbox.kubernetes`` identifiers at parse time.
:raises ValueError: When a name is not an RFC 1123 DNS subdomain/label or a
node-selector entry is not a valid Kubernetes label key/value.
"""
_validate_dns1123_label(namespace, "namespace")
_validate_dns1123_subdomain(secret_name, "secret_name")
_validate_dns1123_subdomain(service_account, "service_account")
for key, value in (node_selector or {}).items():
if not _validate_label_key(key):
raise ValueError(
f"server config 'sandbox.kubernetes.node_selector' has an "
f"invalid label key: {key!r}"
)
if value and (len(value) > 63 or not _K8S_LABEL_SEGMENT_RE.match(value)):
raise ValueError(
f"server config 'sandbox.kubernetes.node_selector[{key}]' has "
f"an invalid label value: {value!r}"
)
def _parse_kubernetes_resources(raw: dict[str, object]) -> dict[str, object] | None:
"""
Extract and validate the optional ``sandbox.kubernetes.resources`` block.
Shape: ``{requests?: {cpu?, memory?}, limits?: {cpu?, memory?}}`` — every
level optional, each ``cpu`` / ``memory`` a non-empty Kubernetes quantity
string. Validated at parse time so an operator typo fails server startup
instead of the first managed launch; an omitted field keeps the default.
:param raw: The raw ``sandbox`` mapping.
:returns: The validated resources block, or ``None`` when omitted.
:raises ValueError: When the block or any field has the wrong shape.
"""
section = _parse_provider_section(raw, "kubernetes")
if section is None:
return None
value = section.get("resources")
if value is None:
return None
if not isinstance(value, dict):
raise ValueError(
"server config 'sandbox.kubernetes.resources' must be a mapping with "
"optional 'requests' / 'limits' blocks"
)
normalized: dict[str, object] = {}
for tier, tier_value in value.items():
if tier not in ("requests", "limits"):
raise ValueError(
f"server config 'sandbox.kubernetes.resources' has an unknown key "
f"{tier!r} (expected 'requests' or 'limits')"
)
if not isinstance(tier_value, dict):
raise ValueError(
f"server config 'sandbox.kubernetes.resources.{tier}' must be a "
"mapping of 'cpu' / 'memory' to quantity strings"
)
norm_tier: dict[str, str] = {}
for field, field_value in tier_value.items():
if field not in ("cpu", "memory"):
raise ValueError(
f"server config 'sandbox.kubernetes.resources.{tier}' has an "
f"unknown key {field!r} (expected 'cpu' or 'memory')"
)
if not isinstance(field_value, str) or not field_value.strip():
raise ValueError(
f"server config 'sandbox.kubernetes.resources.{tier}.{field}' "
"must be a non-empty quantity string, e.g. '500m' or '2Gi'"
)
quantity = field_value.strip()
if not _K8S_QUANTITY_RE.match(quantity):
raise ValueError(
f"server config 'sandbox.kubernetes.resources.{tier}.{field}' "
f"is not a valid Kubernetes quantity: {field_value!r} "
"(e.g. '500m', '2', '1Gi')"
)
norm_tier[field] = quantity
normalized[tier] = norm_tier
return normalized
def _kubernetes_launcher_factory(
*,
image: str | None,
env: list[str] | None,
namespace: str | None,
secret_name: str | None,
service_account: str | None,
node_selector: dict[str, str] | None,
kubeconfig: str | None,
in_cluster: bool | None,
resources: dict[str, object] | None,
) -> Callable[[], SandboxLauncher]:
"""
Build the launcher factory for the YAML ``provider: kubernetes`` path.
:param image: Registry image with omnigent pre-installed, or ``None`` for
the official prebaked host image (env-overridable).
:param env: Names of server-process environment variables injected into
every Pod as literal ``env``, or ``None``. Prefer *secret_name* for
credentials.
:param namespace: Namespace to create Pods in, or ``None`` for the default.
:param secret_name: Pre-created Secret projected into every Pod via
``envFrom`` (harness credentials), or ``None``.
:param service_account: ServiceAccount the Pods run as, or ``None``.
:param node_selector: Extra node selector labels merged with a default
``kubernetes.io/arch: amd64`` (an entry for that key overrides it),
or ``None``.
:param kubeconfig: Explicit kubeconfig path for the out-of-cluster fallback,
or ``None``.
:param in_cluster: Force the cluster-config source, or ``None`` to try
in-cluster then fall back to kubeconfig.
:param resources: Validated ``resources`` block, or ``None`` for defaults.
:returns: A factory producing parameterized Kubernetes launchers.
:raises ValueError: When a name or node-selector label is malformed.
"""
_validate_kubernetes_identifiers(namespace, secret_name, service_account, node_selector)
def _build() -> SandboxLauncher:
"""Construct the Kubernetes launcher (lazy SDK import inside)."""
from omnigent.onboarding.sandboxes.kubernetes import KubernetesSandboxLauncher
return KubernetesSandboxLauncher(
image=image,
env=env,
namespace=namespace,
secret_name=secret_name,
service_account=service_account,
node_selector=node_selector,
kubeconfig=kubeconfig,
in_cluster=in_cluster,
resources=resources,
)
return _build
async def launch_managed_host(
*,
config: ManagedSandboxConfig,
owner: str,
host_store: HostStore,
repo: RepoWorkspace | None = None,
on_stage: Callable[[str], None] | None = None,
) -> ManagedHostLaunch:
"""
Provision a sandbox, start a host in it, and wait until it registers.
Sequence: provision sandbox → pre-register the host row with its
launch-token digest (so the credential resolves by the time the
host dials the tunnel) → optionally clone the requested repository
→ start ``omnigent host`` inside the sandbox with the token +
identity in its environment → poll the hosts table until the host
is online. Any failure after provisioning terminates the sandbox
and deletes the host row (which revokes the token) before
re-raising.
:param config: The deployment's sandbox config (YAML-parsed or
directly constructed with a custom launcher factory).
:param owner: User the managed host acts for — the session
creator, e.g. ``"alice@example.com"`` (or the reserved local
user on single-user servers).
:param host_store: Persistent host registrations — receives the
pre-registered host row and is polled for the sandbox host
coming online.
:param repo: Parsed repository-URL workspace to clone into the
sandbox as the session's working directory, or ``None`` for
an empty workspace. Private repositories authenticate via the
host image's git credential helper when the sandbox env
carries ``GIT_TOKEN`` (injected through Modal secrets — see
deploy/modal/README.md "Git credentials").
:param on_stage: Progress observer invoked as the launch pipeline
advances, with the stage just entered: ``"cloning"`` (when
*repo* is set) then ``"starting"``. May be called from a
worker thread (the sandbox exec steps run via
``asyncio.to_thread``), so it must be thread-safe. ``None``
disables progress reporting.
:returns: The registered host id + in-sandbox workspace path
(the cloned repository directory when *repo* is set).
:raises HTTPException: 400 when the configured provider lacks
managed-launch support; 502 when provisioning, cloning, host
startup, or registration fails.
"""
launcher = config.launcher_factory()
host_id = f"host_{uuid.uuid4().hex}"
# Visible label in the host picker; (owner, name) is the hosts
# table PK, so embed the host_id's leading hex for uniqueness
# across a user's managed sandboxes.
host_name = f"managed-{host_id[len('host_') : len('host_') + 8]}"
try:
await asyncio.to_thread(launcher.prepare)
sandbox_id = await asyncio.to_thread(launcher.provision, host_name)
except click.ClickException as exc:
raise HTTPException(
status_code=502,
detail=f"managed sandbox launch failed: {exc.message}",
) from exc
workspace = await _arm_and_start_host(
launcher=launcher,
config=config,
host_store=host_store,
host_id=host_id,
host_name=host_name,
owner=owner,
sandbox_id=sandbox_id,
repo=repo,
on_stage=on_stage,
)
return ManagedHostLaunch(host_id=host_id, workspace=workspace)
async def relaunch_managed_host(
*,
config: ManagedSandboxConfig,
host: Host,
host_store: HostStore,
repo: RepoWorkspace | None = None,
on_stage: Callable[[str], None] | None = None,
) -> ManagedHostLaunch:
"""
Provision a NEW sandbox generation for an existing managed host.
The host identity is durable while its sandbox is not: when the
sandbox dies (the provider's lifetime cap, a crash, a manual
terminate), the host row and the sessions bound to it remain.
This relaunch keeps that identity — terminate the old sandbox
(best-effort; it is usually already gone), provision a fresh one,
and re-arm the SAME host row with a new token + sandbox id (which
atomically revokes the previous generation's token).
The new sandbox starts from the image — workspace contents of the
dead generation are gone. Passing *repo* re-clones the session's
repository so the workspace is restored to its create-time state.
Unlike a first launch, a failure here keeps the host row (only the
new sandbox is torn down and the armed token revoked), so the
session binding survives and a later attempt can retry.
:param config: The deployment's sandbox config.
:param host: The existing managed host row to relaunch
(``sandbox_provider`` set; callers guard on that).
:param host_store: Persistent host registrations.
:param repo: Repository to re-clone as the workspace, or ``None``
for an empty workspace.
:param on_stage: Progress observer forwarded to
:func:`_arm_and_start_host`; see :func:`launch_managed_host`.
``None`` disables progress reporting.
:returns: The (unchanged) host id + fresh in-sandbox workspace.
:raises HTTPException: 400 when the host's recorded provider no
longer matches the configured launcher; 502 when
provisioning, cloning, host startup, or registration fails.
"""
launcher = _launcher_for_teardown(host, config)
if launcher is None:
raise HTTPException(
status_code=400,
detail=(
f"the '{host.sandbox_provider}' sandbox provider this host "
"was launched with is no longer configured on this server"
),
)
# The old generation is normally already dead (that is why we are
# here), but terminate defensively so a transient tunnel outage
# can never leave two live sandboxes claiming one host identity.
await _terminate_sandbox_best_effort(launcher, host)
try:
await asyncio.to_thread(launcher.prepare)
sandbox_id = await asyncio.to_thread(launcher.provision, host.name)
except click.ClickException as exc:
raise HTTPException(
status_code=502,
detail=f"managed sandbox relaunch failed: {exc.message}",
) from exc
workspace = await _arm_and_start_host(
launcher=launcher,
config=config,
host_store=host_store,
host_id=host.host_id,
host_name=host.name,
owner=host.owner,
sandbox_id=sandbox_id,
repo=repo,
on_stage=on_stage,
keep_host_on_failure=True,
)
return ManagedHostLaunch(host_id=host.host_id, workspace=workspace)
async def _arm_and_start_host(
*,
launcher: SandboxLauncher,
config: ManagedSandboxConfig,
host_store: HostStore,
host_id: str,
host_name: str,
owner: str,
sandbox_id: str,
repo: RepoWorkspace | None = None,
on_stage: Callable[[str], None] | None = None,
keep_host_on_failure: bool = False,
) -> str:
"""
Arm the credential, start the in-sandbox host, and await its
registration — tearing the sandbox down on any failure.
The credential is registered BEFORE the host process starts, so
the token is resolvable by the time the host first dials the
tunnel. A failure in any later step terminates the sandbox and
revokes the armed token before re-raising — by deleting the host
row (first launch: the row would otherwise be an unusable picker
ghost) or, on a relaunch, by clearing the credential columns only
(the durable row keeps the session binding alive for a retry).
:param launcher: The launcher holding the provisioned sandbox.
:param config: The deployment's sandbox config.
:param host_store: Persistent host registrations.
:param host_id: Server-chosen host identity, e.g.
``"host_a1b2c3d4..."``.
:param host_name: Server-chosen host display name, e.g.
``"managed-a1b2c3d4"``.
:param owner: User the managed host acts for, e.g.
``"alice@example.com"``.
:param sandbox_id: The provisioned sandbox, e.g. ``"sb-a1b2c3"``.
:param repo: Repository to clone as the workspace, or ``None``
for an empty workspace.
:param on_stage: Progress observer forwarded to the launcher's
``start_host``; see :func:`launch_managed_host`. ``None``
disables progress reporting.
:param keep_host_on_failure: ``True`` on a relaunch — failure
cleanup terminates the new sandbox and revokes the token but
keeps the host row. ``False`` (first launch) deletes the row.
:returns: The absolute in-sandbox workspace path.
:raises HTTPException: 502 when cloning, host startup, or
registration fails.
"""
token = secrets.token_urlsafe(32)
record = await asyncio.to_thread(
host_store.register_managed_host,
host_id=host_id,
name=host_name,
owner=owner,
token=token,
provider=launcher.provider,
sandbox_id=sandbox_id,
token_expires_at=now_epoch() + config.token_ttl_s,
)
try:
# Uniform across providers: provision() fixed the sandbox id and the
# token was armed against it above, so start_host starts the host with
# a token that already resolves. The exec-model default execs in; the
# entrypoint model (k8s) creates the Pod that boots the host. *repo* is
# unpacked into primitives — the launcher API takes no RepoWorkspace.
workspace = await asyncio.to_thread(
launcher.start_host,
sandbox_id,
token=token,
host_id=host_id,
host_name=host_name,
server_url=config.server_url,
repo_url=repo.url if repo is not None else None,
repo_branch=repo.branch if repo is not None else None,
repo_name=repo.repo_name if repo is not None else None,
on_stage=on_stage,
)
await _wait_for_host_online(host_store, host_id)
except Exception as exc:
# Broad on purpose: any post-provision failure — launcher CLI
# errors, provider SDK exceptions (e.g. Modal's
# SandboxTerminated), raw network errors from the in-sandbox
# exec — must tear down the sandbox and revoke the armed token,
# or the sandbox leaks running until the provider's lifetime
# cap. Cleanup-then-reraise at a system boundary, not a
# swallow: every path below re-raises as an HTTPException.
if keep_host_on_failure:
await _terminate_sandbox_best_effort(launcher, record)
await asyncio.to_thread(host_store.revoke_launch_token, host_id)
else:
await terminate_managed_host(record, host_store, config)
if isinstance(exc, HTTPException):
raise
message = exc.message if isinstance(exc, click.ClickException) else str(exc)
raise HTTPException(
status_code=502,
detail=f"managed sandbox host startup failed: {message}",
) from exc
return workspace
async def _wait_for_host_online(host_store: HostStore, host_id: str) -> None:
"""
Poll the hosts table until the sandbox host registers, or time out.
:param host_store: Persistent host registrations.
:param host_id: The launched host's identifier.
:raises HTTPException: 502 when the host does not come online
within :data:`MANAGED_HOST_ONLINE_TIMEOUT_S`.
"""
deadline = time.monotonic() + MANAGED_HOST_ONLINE_TIMEOUT_S
while time.monotonic() < deadline:
if await asyncio.to_thread(host_store.is_online, host_id):
return
await asyncio.sleep(_ONLINE_POLL_INTERVAL_S)
raise HTTPException(
status_code=502,
detail=(
f"managed host did not come online within "
f"{MANAGED_HOST_ONLINE_TIMEOUT_S}s — check {_HOST_LOG_PATH} "
"inside the sandbox"
),
)
def _launcher_for_teardown(
host: Host,
config: ManagedSandboxConfig | None,
) -> SandboxLauncher | None:
"""
Resolve the launcher that can terminate a managed host's sandbox.
The deployment's CURRENT launcher factory is only usable when its
provider matches the provider recorded on the host row at launch —
a config change between launch and teardown must not aim a
different provider's terminate at a stale sandbox id.
:param host: The managed host being torn down.
:param config: The deployment's current sandbox config, or ``None``
when the ``sandbox:`` section has been removed since launch.
:returns: A launcher whose provider matches the row, or ``None``
when no matching launcher is available.
"""
if config is None:
return None
try:
launcher = config.launcher_factory()
except HTTPException:
# The YAML path's unsupported-provider factory raises; there is
# no launcher to terminate with.
return None
if launcher.provider != host.sandbox_provider:
return None
return launcher
def host_resume_supported(
host: Host,
config: ManagedSandboxConfig | None,
) -> bool:
"""
Whether :func:`resume_managed_host` could wake this host in place.
``True`` iff the host is bound to a sandbox whose provider has a
stop/resume lifecycle with a persistent volume
(:attr:`SandboxLauncher.can_resume`) and still matches the deployment's
current launcher. This is the SAME gate :func:`resume_managed_host`
applies before a wake, exposed so the open-session snapshot
(``SessionResponse.host_resumable``) can render a dormant such host as a
wakeable "asleep" state instead of the terminal ``host_offline``
dead-end.
:param host: The session's bound managed host.
:param config: The deployment's current sandbox config, or ``None``
when the ``sandbox:`` section has been removed since launch.
:returns: ``True`` when a wake would be attempted; ``False`` for a
non-managed / non-resumable provider, a dropped config, or a
host with no recorded ``sandbox_id``.
"""
launcher = _launcher_for_teardown(host, config)
return launcher is not None and launcher.can_resume and host.sandbox_id is not None
# ── Managed-host wake (resume a dormant host on demand) ─────────────────────
# Per-host resume single-flight: one in-flight resume per host_id on this
# replica, else two host processes flap the tunnel registration. Reused across a
# host's many idle-stop/resume cycles, so not reaped — a .pop() could also race
# a resume still holding it; one idle Lock per host woken is negligible.
_resume_locks: dict[str, asyncio.Lock] = {}
async def resume_managed_host(
host_id: str,
host_store: HostStore,
config: ManagedSandboxConfig | None,
) -> None:
"""
Wake a dormant managed host so a session bound to it can run again.
The send-message relaunch path calls this when a host-bound session has no
live runner. If the host is a *resumable* managed host — a provider whose
sandbox idle-stops but retains its persistent volume
(:attr:`SandboxLauncher.can_resume`) — and is currently offline, this
resumes the sandbox under the SAME sandbox id, re-arms its launch token,
re-execs ``omnigent host``, and waits for it to re-register. The caller's
existing relaunch then spawns a fresh runner.
No-op when the host is already online, is unknown, or its provider cannot
resume (e.g. Modal — the caller falls through to its normal host-offline
behavior, i.e. the user starts a new session). Single-flight and
idempotent: concurrent callers serialize on a per-host lock and re-check
liveness under it, so only the first wakes the host.
Unlike a launch, a failed wake does NOT tear the sandbox down — the volume
+ workspace are the user's and must survive for a retry.
:param host_id: The session's bound host id, e.g. ``"host_a1b2c3d4..."``.
:param host_store: Persistent host registrations (cross-replica liveness).
:param config: The deployment's managed-sandbox config, or ``None`` when
the ``sandbox:`` section has been removed since launch.
:raises HTTPException: 502 when the resume or host restart fails.
"""
if config is None:
return
# Cross-replica DB liveness (freshness-gated): never trust the per-replica
# registry alone. Cheap gate before taking the lock.
if await asyncio.to_thread(host_store.is_online, host_id):
return
host = await asyncio.to_thread(host_store.get_host, host_id)
if host is None:
return
# Provider-matched launcher (None if config dropped / provider changed).
# Resume needs a reattachable volume; others (e.g. Modal) fall through to
# the caller's host-offline path (the user starts a new session).
launcher = _launcher_for_teardown(host, config)
if launcher is None or not launcher.can_resume or host.sandbox_id is None:
return
sandbox_id = host.sandbox_id
# Single-flight per host (see _resume_locks).
resume_lock = _resume_locks.setdefault(host_id, asyncio.Lock())
async with resume_lock:
# Re-check under the lock: a concurrent waker may have brought the host
# online while we waited.
if await asyncio.to_thread(host_store.is_online, host_id):
return
_logger.info(
"Waking dormant managed host %s (sandbox %s, provider %s)",
host.host_id,
sandbox_id,
launcher.provider,
)
try:
await asyncio.to_thread(launcher.resume, sandbox_id)
# Mint a fresh token: the old one died with the host process's env
# (only its hash persists). register_managed_host's relaunch branch
# overwrites it in place, keeping the host_id's session bindings.
token = secrets.token_urlsafe(32)
await asyncio.to_thread(
host_store.register_managed_host,
host_id=host.host_id,
name=host.name,
owner=host.owner,
token=token,
provider=launcher.provider,
sandbox_id=sandbox_id,
token_expires_at=now_epoch() + config.token_ttl_s,
)
await asyncio.to_thread(
launcher.start_host,
sandbox_id,
token=token,
host_id=host.host_id,
host_name=host.name,
server_url=config.server_url,
repo_url=None, # the persistent volume already holds the workspace
)
await _wait_for_host_online(host_store, host.host_id)
except Exception as exc:
# A failed wake must NOT tear the sandbox down (the volume is the
# user's); just surface it.
if isinstance(exc, HTTPException):
raise
message = exc.message if isinstance(exc, click.ClickException) else str(exc)
raise HTTPException(
status_code=502, detail=f"managed host wake failed: {message}"
) from exc
async def terminate_managed_host(
host: Host,
host_store: HostStore,
config: ManagedSandboxConfig | None,
) -> None:
"""
Terminate a managed host's sandbox and delete its host row.
Deleting the row is both teardown and revocation in one operation:
the host disappears from the picker AND its launch token stops
resolving. Best-effort on the sandbox side: termination failures
(or a missing/mismatched launcher after a config change) are
logged, not raised — the provider's lifetime cap reaps stragglers,
and the caller (session delete / launch-failure cleanup) must not
be blocked by provider hiccups.
:param host: The managed host to tear down (``sandbox_provider`` /
``sandbox_id`` set; callers guard on that).
:param host_store: Store holding the host row.
:param config: The deployment's current sandbox config (supplies
the launcher for the provider-side terminate), or ``None``
when managed hosts are no longer configured.
"""
launcher = _launcher_for_teardown(host, config)
await _terminate_sandbox_best_effort(launcher, host)
await asyncio.to_thread(host_store.delete_host, host.host_id)
async def _terminate_sandbox_best_effort(
launcher: SandboxLauncher | None,
host: Host,
) -> None:
"""
Terminate a managed host's sandbox without touching its row.
Best-effort by design: termination failures (or a
missing/mismatched launcher after a config change) are logged, not
raised — the provider's lifetime cap reaps stragglers, and callers
(session delete, launch-failure cleanup, relaunch) must not be
blocked by provider hiccups.
:param launcher: Provider-matched launcher from
:func:`_launcher_for_teardown`, or ``None`` when no matching
launcher is available (logged, nothing terminated).
:param host: The host whose ``sandbox_id`` names the sandbox.
"""
if launcher is not None and host.sandbox_id is not None:
try:
await asyncio.to_thread(launcher.terminate, host.sandbox_id)
except Exception: # noqa: BLE001 — deliberate broad catch: this is a
# provider-API boundary on a cleanup path. The provider SDK can
# fail here in many shapes (auth/config ClickException, network
# errors, SDK-internal exceptions), the sandbox may already be
# gone past its lifetime cap, and NONE of those may block the
# caller's remaining cleanup (deleting the host row / revoking
# the launch token), which only we can do.
_logger.warning(
"Failed to terminate managed sandbox %s (provider=%s) for host %s",
host.sandbox_id,
host.sandbox_provider,
host.host_id,
exc_info=True,
)
else:
_logger.warning(
"No launcher available for managed sandbox provider %s; "
"sandbox %s must be deleted with the provider's own tooling",
host.sandbox_provider,
host.sandbox_id,
)