"""Where this profile's desktop lives: the gateway host or inside the configured terminal backend. ``bot_desktop.placement``: ``auto`` (default) follow the terminal backend when it is a sandbox that can host a stream (docker / ssh / singularity); the gateway host when ``terminal.backend`` is local. A sandbox backend that CANNOT host one (modal, daytona, vercel) resolves to ``refused``: the user chose a sandbox for the agent's actions, so quietly running the screen, cua-driver and the browser on the host beside it would hand the agent a desktop outside that sandbox. ``terminal`` always the terminal backend; error when it cannot host one. ``gateway`` always the gateway host (the pre-#108914 behaviour for sandboxed users, now an explicit opt-in because it is the boundary-crossing shape). ``resolve()`` is pure config + backend-class reasoning: it never starts a sandbox. ``terminal_environment()`` does acquire the profile's terminal environment (creating the container if needed) because a screen cannot exist before the sandbox does. """ from __future__ import annotations import logging from dataclasses import dataclass from typing import Any, Optional logger = logging.getLogger(__name__) GATEWAY = "gateway" TERMINAL = "terminal" REFUSED = "refused" _STREAM_BACKENDS = ("docker", "ssh", "singularity") @dataclass(frozen=True) class Placement: where: str # GATEWAY | TERMINAL | REFUSED backend: str # terminal.backend as configured reason: str = "" # human sentence when REFUSED (or why TERMINAL was not possible) def _setting() -> str: from hermes_cli.config import load_config_readonly cfg = load_config_readonly().get("bot_desktop") or {} value = str(cfg.get("placement") or "auto").strip().lower() return value if value in ("auto", TERMINAL, GATEWAY) else "auto" def _terminal_backend() -> str: from tools.terminal_tool import _get_env_config return str(_get_env_config().get("env_type") or "local") def resolve() -> Placement: # Backend first (env only): a local terminal IS the gateway host whatever the setting says, and that is # the common case — it must not cost a config load (this runs on every browser / cua-driver spawn). backend = _terminal_backend() if backend == "local": return Placement(GATEWAY, backend, "terminal.backend is local, so the terminal IS the gateway host") setting = _setting() if setting == GATEWAY: return Placement(GATEWAY, backend) if backend in _STREAM_BACKENDS: return Placement(TERMINAL, backend) reason = (f"terminal.backend is {backend}, which cannot host a screen yet, and running the desktop on the " f"gateway host would put the agent's screen, browser and computer_use outside the sandbox you chose " f"for it. Set bot_desktop.placement: gateway to allow that explicitly.") return Placement(REFUSED, backend, reason) def terminal_environment(*, create: bool = True) -> Optional[Any]: """This profile's terminal environment object (the one ``terminal`` runs commands in). ``create=False`` only returns an already-running one.""" from tools import terminal_tool as tt task_id = tt._resolve_container_task_id(None) with tt._env_lock: env = tt._lookup_active_env(task_id, None) if env is not None or not create: return env # Acquire through the same planner the terminal tool uses so the container carries the same mounts, # limits and identity label; a trivial command warms it. tt.terminal_tool("true", task_id=None) with tt._env_lock: return tt._lookup_active_env(task_id, None)