"""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`` / ``/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 ``[#]`` 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 ``#`` 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 ``#`` 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):: [#] := https:/// | git@: 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:////'" ) 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@:/'" ) else: raise ValueError( f"'{url}' is not a supported repository URL — use " "'https:////' or 'git@:/'" ) 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.`` 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.`` 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, )