From 0484910787df66ee5527d67d102ade80020b54f3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:57:31 -0700 Subject: [PATCH] feat(terminal): pluggable terminal environment backends via plugin registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-party sandbox vendors can now ship a terminal backend as a standalone plugin instead of landing in core. Adds the five-piece pluggable-subsystem pattern for terminal environments: - agent/terminal_env_provider.py — TerminalEnvironmentProvider ABC with declarative classification flags (is_remote, is_container, skip_container_guards, cache_path_base, strip_env_keys, session_isolated_when_nonpersistent) so every historical frozenset-of-names classification site consults the registry instead - agent/terminal_env_registry.py — thread-safe scoped registry; built-in backend names are reserved and unregistrable - PluginContext.register_terminal_environment_provider() mirroring register_browser_provider - _create_environment falls through to registered providers; unknown-backend errors list plugin names - Classification sites wired: approval guard skip, container path/cwd handling (terminal/file/code-exec), prompt-builder env hints + probe, host env probe suppression, skills remote-env note, cache path translation, subprocess secret stripping (both spawn paths), per-session isolation for name-resumed sandboxes - Surfaces: hermes setup picker + doctor + status rows, dashboard terminal-backend picker rows/probe/validation, terminal.backend schema options recomputed per request - Docs: developer-guide/terminal-environment-plugin.md + sidebar + plugins capability table --- agent/prompt_builder.py | 39 ++- agent/terminal_env_provider.py | 222 ++++++++++++++++++ agent/terminal_env_registry.py | 221 +++++++++++++++++ hermes_cli/doctor.py | 28 +++ hermes_cli/plugins.py | 65 +++++ hermes_cli/setup.py | 32 +++ hermes_cli/status.py | 15 ++ hermes_cli/web_routers/tools.py | 14 +- hermes_cli/web_server.py | 60 +++++ tests/agent/test_terminal_env_registry.py | 204 ++++++++++++++++ tools/approval.py | 13 +- tools/code_execution_tool.py | 4 +- tools/credential_files.py | 13 +- tools/env_probe.py | 14 +- tools/environments/local.py | 23 ++ tools/file_tools.py | 16 +- tools/skills_tool.py | 16 +- tools/terminal_tool.py | 118 ++++++++-- .../terminal-environment-plugin.md | 140 +++++++++++ website/docs/user-guide/features/plugins.md | 1 + website/sidebars.ts | 1 + 21 files changed, 1224 insertions(+), 35 deletions(-) create mode 100644 agent/terminal_env_provider.py create mode 100644 agent/terminal_env_registry.py create mode 100644 tests/agent/test_terminal_env_registry.py create mode 100644 website/docs/developer-guide/terminal-environment-plugin.md diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 4f26ffcf2a..0e9359ad88 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -1195,6 +1195,35 @@ _REMOTE_TERMINAL_BACKENDS = frozenset({ # Only states what we know from the backend choice itself (container type, # likely OS family). Does NOT invent cwd, user, or $HOME — the agent is # told to probe those directly if it needs them. +def _plugin_backend_is_remote(backend: str) -> bool: + """Whether a plugin-registered terminal backend runs commands remotely. + + Fail-soft: unknown names return False (treated as local, matching the + historical behavior for unrecognized TERMINAL_ENV values). + """ + if not backend or backend in _REMOTE_TERMINAL_BACKENDS or backend == "local": + return False + try: + from agent.terminal_env_registry import provider_flag + + return bool(provider_flag(backend, "is_remote", False)) + except Exception: + return False + + +def _plugin_backend_description(backend: str) -> str | None: + """Prompt fallback description declared by a plugin backend, if any.""" + try: + from agent.terminal_env_registry import get_provider + + provider = get_provider(backend) + if provider is not None: + return provider.env_description + except Exception: + pass + return None + + _BACKEND_FALLBACK_DESCRIPTIONS: dict[str, str] = { "docker": "a Docker container (Linux)", "singularity": "a Singularity container (Linux)", @@ -1309,7 +1338,9 @@ def _probe_remote_backend(env_type: str) -> str | None: } container_config = None - if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}: + from tools.terminal_tool import _is_container_backend as _is_container + + if _is_container(env_type): container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), @@ -1413,7 +1444,7 @@ def build_environment_hints() -> str: hints: list[str] = [] backend = (os.getenv("TERMINAL_ENV") or "local").strip().lower() - is_remote_backend = backend in _REMOTE_TERMINAL_BACKENDS + is_remote_backend = backend in _REMOTE_TERMINAL_BACKENDS or _plugin_backend_is_remote(backend) if not is_remote_backend: # --- Host info block (local backend: host == where tools run) --- @@ -1461,7 +1492,9 @@ def build_environment_hints() -> str: ) else: description = _BACKEND_FALLBACK_DESCRIPTIONS.get( - backend, f"a {backend} environment (likely Linux)" + backend, + ) or _plugin_backend_description(backend) or ( + f"a {backend} environment (likely Linux)" ) hints.append( f"Terminal backend: {backend}. Your `terminal`, `read_file`, " diff --git a/agent/terminal_env_provider.py b/agent/terminal_env_provider.py new file mode 100644 index 0000000000..b7f28e5a1a --- /dev/null +++ b/agent/terminal_env_provider.py @@ -0,0 +1,222 @@ +""" +Terminal Environment Provider ABC +================================= + +Defines the pluggable-backend interface for terminal execution environments +(cloud sandboxes, remote runners). Providers register instances via +:meth:`PluginContext.register_terminal_environment_provider`; the dispatch +ladder in :func:`tools.terminal_tool._create_environment` consults the +registry for any ``TERMINAL_ENV`` / ``terminal.backend`` value that is not a +built-in backend (local, docker, singularity, modal, daytona, +vercel_sandbox, ssh). + +Providers live in ``~/.hermes/plugins//`` (user, opt-in via +``plugins.enabled``) or ship as standalone plugin repos. Built-in backends +stay in-tree under ``tools/environments/`` — this extension point exists so +third-party sandbox vendors do NOT have to live in core (see AGENTS.md: +"Third-party products ... ship them as a standalone plugin repo"). + +This ABC mirrors :class:`agent.browser_provider.BrowserProvider` — same +registration flow, same scope semantics, same plugin-context gating. + +Classification contract +----------------------- + +Beyond creating environments, a terminal backend participates in several +core policy decisions that were historically frozensets of built-in names. +Each is expressed as a declarative attribute so the class of "new backend +missed classification site N" bugs (see PR #30112's seven-site sweep) cannot +recur for plugin backends: + +* ``is_remote`` — commands run somewhere other than the host machine. + Suppresses host OS/home/cwd hints in the system prompt, the host Python + env probe, and remote-aware skill env handling. +* ``is_container`` — the backend behaves like a container/sandbox with its + own filesystem rooted away from the host: container resource config is + passed through, host-looking cwds are sanitized, and file tools use + container path resolution. +* ``skip_container_guards`` — the sandbox is isolated enough that + dangerous-command approval prompts are skipped (a wiped filesystem is + disposable). Defaults to ``is_container``. Backends that can mount host + paths should override to ``False``. +* ``cache_path_base`` — where auto-synced ``~/.hermes/cache`` files land + inside the backend (e.g. ``"~/.hermes"`` for home-synced backends, + ``"/root/.hermes"`` for root-homed containers), or ``None`` when host + paths remain correct (nothing is translated). +* ``strip_env_keys`` — credential env var names owned by this backend + (API tokens for the sandbox vendor). Stripped from every subprocess the + agent spawns so a model-authored command can never read them. +* ``session_isolated_when_nonpersistent`` — non-persistent mode gives each + session its own sandbox identity instead of sharing one (the #82731 + contract; opt in when a shared name would let two ephemeral runs attach + and destroy each other's sandbox). + +Environment object contract +--------------------------- + +:meth:`create_environment` returns an object satisfying the same duck-typed +interface as :class:`tools.environments.base.BaseEnvironment` (``execute()``, +``cleanup()`` …). Subclassing ``BaseEnvironment`` is recommended but not +required — the registry does not isinstance-check the returned environment. +The factory stamps ``_hermes_backend_name`` on the returned object so +file-path resolution can identify plugin backends without class-name +sniffing. +""" + +from __future__ import annotations + +import abc +from typing import Any, Dict, List, Optional, Tuple + + +class TerminalEnvironmentProvider(abc.ABC): + """Abstract base class for a pluggable terminal execution backend.""" + + # ------------------------------------------------------------------ + # Identity + # ------------------------------------------------------------------ + + @property + @abc.abstractmethod + def name(self) -> str: + """Stable short identifier used as the ``terminal.backend`` / + ``TERMINAL_ENV`` value. + + Lowercase, ``[a-z0-9_]``. Must not collide with a built-in backend + name (local, docker, singularity, modal, managed_modal, daytona, + vercel_sandbox, ssh) — the registry rejects such registrations. + """ + + @property + def display_name(self) -> str: + """Human-readable label for pickers. Defaults to ``name``.""" + return self.name + + @property + def description(self) -> str: + """One-line description shown in backend pickers.""" + return f"Run commands in a {self.display_name} environment." + + # ------------------------------------------------------------------ + # Classification flags (see module docstring) + # ------------------------------------------------------------------ + + is_remote: bool = True + is_container: bool = True + session_isolated_when_nonpersistent: bool = False + + @property + def skip_container_guards(self) -> bool: + """Whether dangerous-command approval prompts are skipped.""" + return self.is_container + + @property + def cache_path_base(self) -> Optional[str]: + """Base dir for synced Hermes cache files inside the backend.""" + return None + + @property + def strip_env_keys(self) -> frozenset: + """Backend-owned credential env var names to strip from subprocesses.""" + return frozenset() + + @property + def env_description(self) -> str: + """Prompt-builder fallback description of where commands run. + + Used when the live backend probe fails at system-prompt build time, + e.g. ``"a Daytona workspace (Linux)"``. + """ + return f"a {self.display_name} environment (likely Linux)" + + # ------------------------------------------------------------------ + # Availability / setup UX + # ------------------------------------------------------------------ + + @abc.abstractmethod + def is_available(self) -> bool: + """Return True when this backend can service commands. + + Cheap check only (env var present, SDK importable). Must NOT make + network calls — this runs during requirement checks and UI paints. + """ + + def check_requirements(self, config: Dict[str, Any]) -> bool: + """Full requirements check for :func:`check_terminal_requirements`. + + ``config`` is the merged terminal env config dict. Default defers to + :meth:`is_available`. Log actionable errors before returning False. + """ + return self.is_available() + + def probe(self) -> Tuple[str, str]: + """Dashboard picker health probe: ``(status, detail)``. + + ``status`` is ``"ready"`` / ``"needs_setup"`` / ``"unavailable"``; + ``detail`` carries setup guidance for non-ready rows. Must never + raise and must stay fast (<~2s). + """ + if self.is_available(): + return ("ready", "") + return ("needs_setup", f"{self.display_name} is not configured.") + + def setup_instructions(self) -> List[str]: + """Lines printed by ``hermes setup`` after this backend is selected. + + Use for token acquisition hints, SDK install commands, etc. The + wizard persists ``terminal.backend`` itself; providers that need an + interactive flow can run it in :meth:`post_setup`. + """ + return [] + + def post_setup(self) -> None: + """Optional interactive setup hook run by ``hermes setup`` after the + backend is selected (prompt for tokens, install SDKs). Default no-op. + """ + + def doctor_checks(self) -> List[Tuple[bool, str, str]]: + """``hermes doctor`` rows: ``(ok, label, detail)`` triples. + + Default: a single row reflecting :meth:`is_available`. + """ + ok = False + try: + ok = bool(self.is_available()) + except Exception: + ok = False + detail = "(configured)" if ok else "(not configured — see setup instructions)" + return [(ok, f"{self.display_name} backend", detail)] + + # ------------------------------------------------------------------ + # The factory + # ------------------------------------------------------------------ + + @abc.abstractmethod + def create_environment( + self, + *, + cwd: str, + timeout: int, + task_id: str = "default", + image: Optional[str] = None, + container_config: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ): + """Create and return an execution environment instance. + + MUST accept ``**kwargs`` and ignore unknown keys — the forward-compat + contract that lets the factory signature evolve without breaking + older plugins. + + Args: + cwd: Working directory inside the backend. + timeout: Default per-command timeout in seconds. + task_id: Task identifier for environment reuse/persistence keying. + image: Configured container image name (may be irrelevant). + container_config: Resource config dict (``container_cpu``, + ``container_memory``, ``container_disk``, + ``container_persistent``) when :attr:`is_container` is True. + + Returns: + An object satisfying the ``BaseEnvironment`` duck-typed contract. + """ diff --git a/agent/terminal_env_registry.py b/agent/terminal_env_registry.py new file mode 100644 index 0000000000..3c65cd2242 --- /dev/null +++ b/agent/terminal_env_registry.py @@ -0,0 +1,221 @@ +""" +Terminal Environment Registry +============================= + +Central map of registered pluggable terminal backends. Populated by plugins +at load time via :meth:`PluginContext.register_terminal_environment_provider`; +consumed by :func:`tools.terminal_tool._create_environment` and the +classification helpers spread across the terminal/file/approval/prompt +surfaces. + +Unlike the image/video/web/browser registries there is **no active-provider +resolution here**: the active backend is whatever ``TERMINAL_ENV`` / +``terminal.backend`` names, exactly as for built-in backends. The registry's +only job is mapping that name to a provider instance (and answering the +classification questions the core historically answered with frozensets of +built-in names). + +Built-in backend names are reserved — :func:`register_provider` rejects a +provider whose ``name`` collides with one, so a plugin can never shadow the +in-tree docker/modal/... implementations. + +Mirrors :mod:`agent.browser_registry` scope semantics: providers register +into a per-profile scope (multiplexed gateways) or the global base map. +""" + +from __future__ import annotations + +import logging +import threading +from typing import Dict, List, Optional + +from agent.terminal_env_provider import TerminalEnvironmentProvider +from hermes_constants import hermes_home_key + +logger = logging.getLogger(__name__) + + +#: Names owned by in-tree backends in tools/environments/ — never +#: registrable by plugins. Includes internal-mode aliases (managed_modal). +BUILTIN_BACKEND_NAMES = frozenset({ + "local", "docker", "singularity", "modal", "managed_modal", + "daytona", "vercel_sandbox", "ssh", +}) + + +_providers: Dict[str, TerminalEnvironmentProvider] = {} +_scoped_providers: Dict[str, Dict[str, TerminalEnvironmentProvider]] = {} +_generation = 0 +_scoped_generations: Dict[str, int] = {} +_lock = threading.Lock() + + +def register_provider( + provider: TerminalEnvironmentProvider, *, scope: Optional[str] = None +) -> None: + """Register a terminal environment provider. + + Re-registration (same ``name``) overwrites the previous entry — makes + hot-reload scenarios (tests, dev loops) behave predictably. + + Raises: + TypeError: not a TerminalEnvironmentProvider instance. + ValueError: empty name or collision with a built-in backend name. + """ + if not isinstance(provider, TerminalEnvironmentProvider): + raise TypeError( + f"register_provider() expects a TerminalEnvironmentProvider " + f"instance, got {type(provider).__name__}" + ) + raw_name = provider.name + if not isinstance(raw_name, str) or not raw_name.strip(): + raise ValueError("Terminal environment provider .name must be a non-empty string") + name = raw_name.strip().lower() + if name in BUILTIN_BACKEND_NAMES: + raise ValueError( + f"Terminal backend name '{name}' is reserved for the built-in " + f"{name} backend and cannot be registered by a plugin" + ) + global _generation + with _lock: + target = _providers if scope is None else _scoped_providers.setdefault(scope, {}) + existing = target.get(name) + target[name] = provider + if scope is None: + _generation += 1 + else: + _scoped_generations[scope] = _scoped_generations.get(scope, 0) + 1 + if existing is not None: + logger.debug( + "Terminal environment provider '%s' re-registered (was %r)", + name, type(existing).__name__, + ) + else: + logger.debug( + "Registered terminal environment provider '%s' (%s)", + name, type(provider).__name__, + ) + + +def list_providers(*, scope: Optional[str] = None) -> List[TerminalEnvironmentProvider]: + """Return all registered providers, sorted by name.""" + with _lock: + merged = dict(_providers) + merged.update(_scoped_providers.get(scope or hermes_home_key(), {})) + items = list(merged.values()) + return sorted(items, key=lambda p: p.name) + + +def get_provider( + name: str, *, scope: Optional[str] = None +) -> Optional[TerminalEnvironmentProvider]: + """Return the provider registered under *name*, or None.""" + if not isinstance(name, str): + return None + key = name.strip().lower() + with _lock: + return ( + _scoped_providers.get(scope or hermes_home_key(), {}).get(key) + or _providers.get(key) + ) + + +def plugin_backend_names(*, scope: Optional[str] = None) -> List[str]: + """Names of all registered plugin backends (sorted).""" + return [p.name.strip().lower() for p in list_providers(scope=scope)] + + +def provider_flag(name: str, attr: str, default=False): + """Read a classification attribute off the provider for *name*. + + Fail-soft: unknown backend or a raising property returns *default* so a + misbehaving plugin degrades to built-in-equivalent behavior instead of + taking the terminal tool down. + """ + provider = get_provider(name) + if provider is None: + return default + try: + return getattr(provider, attr, default) + except Exception: + logger.debug( + "Terminal environment provider '%s' attribute '%s' raised", + name, attr, exc_info=True, + ) + return default + + +def plugin_strip_env_keys() -> frozenset: + """Union of every registered provider's ``strip_env_keys``. + + Secrets are stripped for ALL registered backends, not just the active + one — a token in the process environment is strippable regardless of + which backend is selected (mirrors how MODAL_*/DAYTONA_API_KEY sit in + the static tier-1 set unconditionally). + """ + keys: set = set() + with _lock: + all_providers = list(_providers.values()) + for scoped in _scoped_providers.values(): + all_providers.extend(scoped.values()) + for provider in all_providers: + try: + keys.update(provider.strip_env_keys) + except Exception: + logger.debug( + "Terminal environment provider strip_env_keys raised", + exc_info=True, + ) + return frozenset(keys) + + +def snapshot_registration( + name: str, *, scope: Optional[str] = None +) -> Optional[TerminalEnvironmentProvider]: + with _lock: + target = _providers if scope is None else _scoped_providers.get(scope, {}) + return target.get(name.strip().lower()) + + +def registry_generation(*, scope: Optional[str] = None) -> tuple: + """Return a cache fingerprint for the global base and one profile.""" + active_scope = scope or hermes_home_key() + with _lock: + return _generation, _scoped_generations.get(active_scope, 0) + + +def restore_registration( + name: str, + current: TerminalEnvironmentProvider, + previous: Optional[TerminalEnvironmentProvider], + *, + scope: Optional[str] = None, +) -> bool: + """Restore a plugin registration only when *current* is still installed.""" + key = name.strip().lower() + global _generation + with _lock: + target = _providers if scope is None else _scoped_providers.setdefault(scope, {}) + if target.get(key) is not current: + return False + if previous is None: + target.pop(key, None) + else: + target[key] = previous + if scope is None: + _generation += 1 + else: + _scoped_generations[scope] = _scoped_generations.get(scope, 0) + 1 + if not target: + _scoped_providers.pop(scope, None) + return True + + +def _reset_for_tests() -> None: + """Clear all registrations. Test hook — mirrors sibling registries.""" + global _generation + with _lock: + _providers.clear() + _scoped_providers.clear() + _scoped_generations.clear() + _generation = 0 diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 3433607962..90781d5302 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -2253,6 +2253,34 @@ def run_doctor(args): else: check_info("Vercel persistence: ephemeral filesystem") + # Plugin-registered terminal backends (if one is the active backend) + if terminal_env not in { + "local", "docker", "singularity", "modal", "managed_modal", + "daytona", "vercel_sandbox", "ssh", + }: + try: + from hermes_cli.plugins import discover_plugins + + discover_plugins() + from agent.terminal_env_registry import get_provider + + _provider = get_provider(terminal_env) + except Exception: + _provider = None + if _provider is None: + _fail_and_issue( + f"Unknown terminal backend '{terminal_env}'", + "(no built-in or plugin backend by that name)", + "Fix terminal.backend in config.yaml, or install/enable the plugin that provides it", + issues, + ) + else: + for _ok, _label, _detail in _provider.doctor_checks(): + if _ok: + check_ok(_label, _detail) + else: + _fail_and_issue(_label, _detail, _detail.strip("()"), issues) + # Node.js + agent-browser (for browser automation tools) if _safe_which("node"): check_ok("Node.js") diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index b88640df9a..03f46cb4f3 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -2596,6 +2596,71 @@ class PluginContext: ) return handle + # -- terminal environment provider registration ---------------------------- + + @_serialized_replacement + def register_terminal_environment_provider(self, provider) -> Optional[PluginRegistration]: + """Register a pluggable terminal execution backend. + + ``provider`` must be an instance of + :class:`agent.terminal_env_provider.TerminalEnvironmentProvider`. + The ``provider.name`` attribute is what ``terminal.backend`` in + ``config.yaml`` (bridged to ``TERMINAL_ENV``) matches against when + the dispatch ladder in :func:`tools.terminal_tool._create_environment` + finds no built-in backend of that name. + + Names colliding with built-in backends (local, docker, singularity, + modal, daytona, vercel_sandbox, ssh) are rejected by the registry — + plugins extend the backend set, they never shadow in-tree backends. + + Mirrors :meth:`register_browser_provider` — same registration shape, + same gating, same logging. + """ + from agent.terminal_env_provider import TerminalEnvironmentProvider + from agent.terminal_env_registry import ( + register_provider as _register_terminal_env_provider, + restore_registration, + snapshot_registration, + ) + + if not isinstance(provider, TerminalEnvironmentProvider): + logger.warning( + "Plugin '%s' tried to register a terminal environment " + "provider that does not inherit from " + "TerminalEnvironmentProvider. Ignoring.", + self.manifest.name, + ) + return + registry_name = provider.name.strip().lower() + scope = self._manager.scope_key + previous = snapshot_registration(registry_name, scope=scope) + try: + _register_terminal_env_provider(provider, scope=scope) + except ValueError as exc: + logger.warning( + "Plugin '%s' terminal environment provider rejected: %s", + self.manifest.name, exc, + ) + return + registered = snapshot_registration(registry_name, scope=scope) + if registered is not provider: + return None + handle = self._track_replacement( + "terminal_environment_provider", + registry_name, + slot=("terminal_environment_provider", scope, registry_name), + current=provider, + previous=previous, + restore=lambda replacement: restore_registration( + registry_name, provider, replacement, scope=scope + ), + ) + logger.info( + "Plugin '%s' registered terminal environment provider: %s", + self.manifest.name, registry_name, + ) + return handle + # -- secret source registration ------------------------------------------- @_serialized_replacement diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 716eb6f368..7a959bf7fe 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1429,6 +1429,26 @@ def setup_terminal_backend(config: dict): backend_to_idx["singularity"] = next_idx next_idx += 1 + # Plugin-registered terminal backends (standalone plugin repos installed + # under ~/.hermes/plugins/). Fail-soft: a broken plugin must not take the + # setup wizard down. + plugin_backend_names = [] + try: + from hermes_cli.plugins import discover_plugins + + discover_plugins() # idempotent — plugin state may not be loaded yet + from agent.terminal_env_registry import list_providers + + for _provider in list_providers(): + _pname = _provider.name.strip().lower() + terminal_choices.append(f"{_provider.display_name} - {_provider.description}") + idx_to_backend[next_idx] = _pname + backend_to_idx[_pname] = next_idx + plugin_backend_names.append(_pname) + next_idx += 1 + except Exception: + pass + # Add keep current option keep_current_idx = next_idx terminal_choices.append(f"Keep current ({current_backend})") @@ -1670,6 +1690,18 @@ def setup_terminal_backend(config: dict): _prompt_vercel_sandbox_settings(config) + elif selected_backend in plugin_backend_names: + try: + from agent.terminal_env_registry import get_provider + + _provider = get_provider(selected_backend) + print_success(f"Terminal backend: {_provider.display_name}") + for _line in _provider.setup_instructions(): + print_info(_line) + _provider.post_setup() + except Exception as exc: + print_warning(f"Backend plugin setup hook failed: {exc}") + elif selected_backend == "ssh": print_success("Terminal backend: SSH") print_info("Run commands on a remote machine via SSH.") diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 47ac61381d..981bb3a387 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -478,6 +478,21 @@ def show_status(args): print(f" Auth detail: {line}") print(f" Persistence: {'snapshot filesystem' if persist_enabled else 'ephemeral filesystem'}") print(" Processes: live processes do not survive cleanup, snapshots, or sandbox recreation") + else: + # Plugin-registered terminal backends: show availability via the + # provider's doctor rows (fail-soft — never break `hermes status`). + try: + from hermes_cli.plugins import discover_plugins + + discover_plugins() + from agent.terminal_env_registry import get_provider + + _provider = get_provider(terminal_env) + if _provider is not None: + for _ok, _label, _detail in _provider.doctor_checks(): + print(f" {_label}: {check_mark(bool(_ok))} {_detail}") + except Exception: + pass sudo_password = os.getenv("SUDO_PASSWORD", "") print(f" Sudo: {check_mark(bool(sudo_password))} {'enabled' if sudo_password else 'disabled'}") diff --git a/hermes_cli/web_routers/tools.py b/hermes_cli/web_routers/tools.py index 58c9caeba2..7cd5099e23 100644 --- a/hermes_cli/web_routers/tools.py +++ b/hermes_cli/web_routers/tools.py @@ -49,6 +49,10 @@ save_config = late("save_config") _MODEL_CATALOG_TOOLSETS = LateState("_MODEL_CATALOG_TOOLSETS") _TERMINAL_BACKENDS = LateState("_TERMINAL_BACKENDS") _TERMINAL_BACKEND_NAMES = LateState("_TERMINAL_BACKEND_NAMES") +# Dynamic variants: built-ins + plugin-registered backends, computed per +# request so a plugin installed after server start still shows up. +_terminal_backend_rows = late("_terminal_backend_rows") +_terminal_backend_names = late("_terminal_backend_names") # Config read-modify-write serialization for off-loop handlers (defined in # web_server.py; LateState supports ``with``-blocks, so this is the live lock). _CONFIG_MUTATION_LOCK = LateState("_CONFIG_MUTATION_LOCK") @@ -725,12 +729,13 @@ async def get_terminal_backends(profile: Optional[str] = None): terminal_cfg = config.get("terminal") if not isinstance(terminal_cfg, dict): terminal_cfg = {} + rows = _terminal_backend_rows() active = str(terminal_cfg.get("backend") or "local").strip().lower() - if active not in _TERMINAL_BACKEND_NAMES: + if active not in {row["name"] for row in rows}: active = "local" backends = [] - for row in _TERMINAL_BACKENDS: + for row in rows: status, detail = _probe_terminal_backend(row["name"], terminal_cfg) backends.append({ "name": row["name"], @@ -756,11 +761,12 @@ async def select_terminal_backend( allowed — the picker shows guidance instead of blocking, matching the CLI. """ backend = (body.backend or "").strip().lower() - if backend not in _TERMINAL_BACKEND_NAMES: + valid_names = _terminal_backend_names() + if backend not in valid_names: raise HTTPException( status_code=400, detail=f"Unknown terminal backend: {body.backend!r}. " - f"Use one of: {', '.join(sorted(_TERMINAL_BACKEND_NAMES))}", + f"Use one of: {', '.join(sorted(valid_names))}", ) def _run(): diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 201d37f197..bbe3af488a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1607,6 +1607,18 @@ def _schema_with_dynamic_provider_options() -> Dict[str, Dict[str, Any]]: merge("memory.provider", _memory_provider_schema_options(cfg)) + tb_entry = CONFIG_SCHEMA.get("terminal.backend") + if isinstance(tb_entry, dict) and isinstance(tb_entry.get("options"), list): + try: + plugin_names = sorted( + {row["name"] for row in _plugin_terminal_backend_rows()} + - set(tb_entry["options"]) + ) + except Exception: + plugin_names = [] + if plugin_names: + merge("terminal.backend", [*tb_entry["options"], *plugin_names]) + if not overlay: return CONFIG_SCHEMA @@ -15426,6 +15438,46 @@ _TERMINAL_BACKENDS: List[Dict[str, str]] = [ _TERMINAL_BACKEND_NAMES = {row["name"] for row in _TERMINAL_BACKENDS} +def _plugin_terminal_backend_rows() -> List[Dict[str, str]]: + """Picker rows for plugin-registered terminal backends (fail-soft).""" + rows: List[Dict[str, str]] = [] + try: + from hermes_cli.plugins import discover_plugins + + discover_plugins() # idempotent — plugin state may not be loaded yet + except Exception: + pass + try: + from agent.terminal_env_registry import list_providers + + for provider in list_providers(): + try: + rows.append({ + "name": provider.name.strip().lower(), + "label": provider.display_name, + "description": provider.description, + }) + except Exception: + continue + except Exception: + return rows + return rows + + +def _terminal_backend_rows() -> List[Dict[str, str]]: + """Built-in picker rows plus plugin-registered backends (request time). + + Computed per request (mirrors ``_schema_with_dynamic_provider_options``) + so a plugin installed after server start still shows up. + """ + return [*_TERMINAL_BACKENDS, *_plugin_terminal_backend_rows()] + + +def _terminal_backend_names() -> set: + """Valid ``terminal.backend`` values, including plugin backends.""" + return {row["name"] for row in _terminal_backend_rows()} + + def _terminal_cfg_value(terminal_cfg: dict, key: str, env_var: str) -> str: """Read a terminal.* setting from config.yaml, falling back to its env var.""" value = terminal_cfg.get(key) @@ -15538,6 +15590,14 @@ def _probe_terminal_backend(name: str, terminal_cfg: dict) -> tuple: return _probe_modal_backend() if name == "daytona": return _probe_daytona_backend() + try: + from agent.terminal_env_registry import get_provider + + provider = get_provider(name) + if provider is not None: + return provider.probe() + except Exception: + pass return ("unavailable", f"Unknown backend: {name}") except Exception as exc: # pragma: no cover — belt-and-braces guard return ("unavailable", f"Probe failed: {exc}") diff --git a/tests/agent/test_terminal_env_registry.py b/tests/agent/test_terminal_env_registry.py new file mode 100644 index 0000000000..c06708ca72 --- /dev/null +++ b/tests/agent/test_terminal_env_registry.py @@ -0,0 +1,204 @@ +"""Tests for the terminal environment provider registry + ABC. + +Mirrors tests/agent/test_image_gen_registry.py in structure. Covers: +- registration / re-registration / type & name validation +- reserved built-in name rejection +- scoped registrations (multiplexed-gateway profiles) +- classification helpers (provider_flag, plugin_strip_env_keys) +- restore_registration semantics (plugin unload) +- fail-soft behavior for raising providers +""" + +import pytest + +from agent.terminal_env_provider import TerminalEnvironmentProvider +from agent import terminal_env_registry as reg + + +class _Env: + def execute(self, command, **kwargs): + return {"output": "", "exit_code": 0} + + def cleanup(self): + pass + + +class _Provider(TerminalEnvironmentProvider): + name = "testbox" + display_name = "TestBox" + is_remote = True + is_container = True + session_isolated_when_nonpersistent = True + + @property + def cache_path_base(self): + return "~/.hermes" + + @property + def strip_env_keys(self): + return frozenset({"TESTBOX_TOKEN", "TESTBOX_SECRET"}) + + def is_available(self): + return True + + def create_environment(self, *, cwd, timeout, task_id="default", + image=None, container_config=None, **kwargs): + return _Env() + + +@pytest.fixture(autouse=True) +def _clean_registry(): + reg._reset_for_tests() + yield + reg._reset_for_tests() + + +def test_register_and_get(): + p = _Provider() + reg.register_provider(p) + assert reg.get_provider("testbox") is p + assert reg.get_provider("TESTBOX") is p # case-insensitive lookup + assert reg.plugin_backend_names() == ["testbox"] + + +def test_rejects_non_provider(): + with pytest.raises(TypeError): + reg.register_provider(object()) + + +def test_rejects_empty_name(): + class Bad(_Provider): + name = " " + + with pytest.raises(ValueError): + reg.register_provider(Bad()) + + +@pytest.mark.parametrize("reserved", sorted(reg.BUILTIN_BACKEND_NAMES)) +def test_rejects_builtin_names(reserved): + class Shadow(_Provider): + name = reserved + + with pytest.raises(ValueError): + reg.register_provider(Shadow()) + assert reg.get_provider(reserved) is None + + +def test_reregistration_overwrites(): + p1, p2 = _Provider(), _Provider() + reg.register_provider(p1) + reg.register_provider(p2) + assert reg.get_provider("testbox") is p2 + + +def test_scoped_registration_isolated(): + p_global, p_scoped = _Provider(), _Provider() + reg.register_provider(p_global) + reg.register_provider(p_scoped, scope="profile-a") + assert reg.get_provider("testbox", scope="profile-a") is p_scoped + # Different scope falls back to global + assert reg.get_provider("testbox", scope="profile-b") is p_global + + +def test_provider_flag_reads_attributes(): + reg.register_provider(_Provider()) + assert reg.provider_flag("testbox", "is_remote") is True + assert reg.provider_flag("testbox", "is_container") is True + assert reg.provider_flag("testbox", "session_isolated_when_nonpersistent") is True + assert reg.provider_flag("testbox", "cache_path_base", None) == "~/.hermes" + assert reg.provider_flag("testbox", "skip_container_guards") is True + + +def test_provider_flag_unknown_backend_returns_default(): + assert reg.provider_flag("missing", "is_remote", False) is False + assert reg.provider_flag("missing", "cache_path_base", None) is None + + +def test_provider_flag_fail_soft_on_raising_property(): + class Broken(_Provider): + name = "broken" + + @property + def cache_path_base(self): + raise RuntimeError("boom") + + reg.register_provider(Broken()) + assert reg.provider_flag("broken", "cache_path_base", None) is None + + +def test_plugin_strip_env_keys_union(): + class Other(_Provider): + name = "other" + + @property + def strip_env_keys(self): + return frozenset({"OTHER_KEY"}) + + reg.register_provider(_Provider()) + reg.register_provider(Other()) + keys = reg.plugin_strip_env_keys() + assert keys == frozenset({"TESTBOX_TOKEN", "TESTBOX_SECRET", "OTHER_KEY"}) + + +def test_plugin_strip_env_keys_fail_soft(): + class Broken(_Provider): + name = "broken" + + @property + def strip_env_keys(self): + raise RuntimeError("boom") + + reg.register_provider(Broken()) + assert reg.plugin_strip_env_keys() == frozenset() + + +def test_restore_registration_unregisters(): + p = _Provider() + reg.register_provider(p) + assert reg.restore_registration("testbox", p, None) is True + assert reg.get_provider("testbox") is None + + +def test_restore_registration_noop_when_replaced(): + p1, p2 = _Provider(), _Provider() + reg.register_provider(p1) + reg.register_provider(p2) # p1 replaced + assert reg.restore_registration("testbox", p1, None) is False + assert reg.get_provider("testbox") is p2 + + +def test_restore_registration_restores_previous(): + p1, p2 = _Provider(), _Provider() + reg.register_provider(p1) + reg.register_provider(p2) + assert reg.restore_registration("testbox", p2, p1) is True + assert reg.get_provider("testbox") is p1 + + +def test_abc_defaults(): + p = _Provider() + assert p.skip_container_guards is True # defaults to is_container + assert "TestBox" in p.env_description + assert p.probe() == ("ready", "") + assert p.setup_instructions() == [] + rows = p.doctor_checks() + assert rows and rows[0][0] is True + + +def test_probe_needs_setup_when_unavailable(): + class Off(_Provider): + name = "offbox" + + def is_available(self): + return False + + status, detail = Off().probe() + assert status == "needs_setup" + assert detail + + +def test_registry_generation_bumps(): + g0 = reg.registry_generation() + reg.register_provider(_Provider()) + g1 = reg.registry_generation() + assert g1 != g0 diff --git a/tools/approval.py b/tools/approval.py index 775aa85972..b0b5750f31 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -3916,7 +3916,18 @@ def _should_skip_container_guards(env_type: str, has_host_access: bool = False) """ if env_type == "docker": return not has_host_access - return env_type in ("singularity", "modal", "daytona", "vercel_sandbox") + if env_type in ("singularity", "modal", "daytona", "vercel_sandbox"): + return True + if env_type in ("local", "ssh"): + return False + # Plugin-registered backends: honor their declarative flag. Fail-soft + # to False — an unknown backend keeps the approval layer ON. + try: + from agent.terminal_env_registry import provider_flag + + return bool(provider_flag(env_type, "skip_container_guards", False)) + except Exception: + return False def check_dangerous_command(command: str, env_type: str, diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index fe5366d0b0..97ee9f8608 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -833,7 +833,9 @@ def _get_or_create_env(task_id: str): cwd = overrides.get("cwd") or config["cwd"] container_config = None - if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}: + from tools.terminal_tool import _is_container_backend as _is_container + + if _is_container(env_type): container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), diff --git a/tools/credential_files.py b/tools/credential_files.py index b429d4efa7..e7cc8f028e 100644 --- a/tools/credential_files.py +++ b/tools/credential_files.py @@ -552,7 +552,18 @@ def to_agent_visible_cache_path( elif backend in ("ssh", "daytona", "vercel_sandbox"): container_base = "~/.hermes" else: - return host_path # local, singularity, unknown: host path is correct + # Plugin-registered backends declare where synced cache files land + # via ``cache_path_base``; None means host paths remain correct. + plugin_base = None + try: + from agent.terminal_env_registry import provider_flag + + plugin_base = provider_flag(backend, "cache_path_base", None) + except Exception: + plugin_base = None + if not plugin_base: + return host_path # local, singularity, unknown: host path is correct + container_base = str(plugin_base) mapped = map_cache_path_to_container(host_path, container_base=container_base) return mapped if mapped is not None else host_path diff --git a/tools/env_probe.py b/tools/env_probe.py index f656af3edc..32b8f6e6b6 100644 --- a/tools/env_probe.py +++ b/tools/env_probe.py @@ -78,6 +78,18 @@ _REMOTE_BACKENDS = frozenset({ }) +def _plugin_backend_is_remote(backend: str) -> bool: + """Whether a plugin-registered terminal backend is remote (fail-soft).""" + if not backend or backend in _REMOTE_BACKENDS or backend == "local": + return False + try: + from agent.terminal_env_registry import provider_flag + + return bool(provider_flag(backend, "is_remote", False)) + except Exception: + return False + + def _run(cmd: list[str], timeout: float = 3.0) -> tuple[int, str, str]: """Run a short subprocess. Returns (returncode, stdout, stderr). @@ -195,7 +207,7 @@ def _build_probe_line() -> str: # Bail out if a remote terminal backend is configured; the host's # Python state isn't where the agent's tools run. backend = (os.getenv("TERMINAL_ENV") or "local").strip().lower() - if backend in _REMOTE_BACKENDS: + if backend in _REMOTE_BACKENDS or _plugin_backend_is_remote(backend): return "" py3_ver = _python_version_of("python3") diff --git a/tools/environments/local.py b/tools/environments/local.py index 90cef0362e..661067ebe6 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -408,6 +408,22 @@ def _is_hermes_internal_secret(key: str) -> bool: return False +def _plugin_terminal_env_strip_keys() -> frozenset: + """Credential env keys owned by plugin-registered terminal backends. + + Computed at call time (not import time) because plugins register after + this module is imported. Treated as Tier-1: stripped from every spawned + subprocess unconditionally, exactly like MODAL_*/DAYTONA_API_KEY in + ``_ALWAYS_STRIP_KEYS``. Fail-soft to an empty set. + """ + try: + from agent.terminal_env_registry import plugin_strip_env_keys + + return plugin_strip_env_keys() + except Exception: + return frozenset() + + def _inject_context_hermes_home(env: dict) -> None: """Bridge the context-local Hermes home override into subprocess env.""" try: @@ -479,12 +495,15 @@ def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = Non _resolve_passthrough_value = lambda _name, fallback: fallback # noqa: E731 sanitized: dict[str, str] = {} + _plugin_strip = _plugin_terminal_env_strip_keys() for key, value in (base_env or {}).items(): if key.startswith(_HERMES_PROVIDER_ENV_FORCE_PREFIX): continue if _is_hermes_internal_secret(key): continue + if key in _plugin_strip: + continue passthrough = _is_passthrough(key) if key in _HERMES_PROVIDER_ENV_BLOCKLIST and not passthrough: continue @@ -500,6 +519,8 @@ def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = Non sanitized[real_key] = value elif _is_hermes_internal_secret(key): continue + elif key in _plugin_strip: + continue else: passthrough = _is_passthrough(key) if key in _HERMES_PROVIDER_ENV_BLOCKLIST and not passthrough: @@ -633,6 +654,8 @@ def hermes_subprocess_env(*, inherit_credentials: bool = False) -> dict[str, str # Tier 1 — always strip. for key in _ALWAYS_STRIP_KEYS: env.pop(key, None) + for key in _plugin_terminal_env_strip_keys(): + env.pop(key, None) # Internal routing hints and Hermes-internal dynamic secrets # (``AUXILIARY__API_KEY`` / ``_BASE_URL`` side-LLM credentials, # ``GATEWAY_RELAY_*`` relay-auth material) must never reach a child, diff --git a/tools/file_tools.py b/tools/file_tools.py index c54cb687f7..bbed5edfd9 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -202,6 +202,9 @@ def _terminal_env_type_for_task(task_id: str = "default") -> str: return "modal" if "daytona" in name: return "daytona" + stamped = getattr(env, "_hermes_backend_name", None) + if isinstance(stamped, str) and stamped: + return stamped cfg = _get_env_config() return str(cfg.get("env_type") or os.getenv("TERMINAL_ENV") or "local").lower() except Exception: @@ -209,12 +212,13 @@ def _terminal_env_type_for_task(task_id: str = "default") -> str: def _uses_container_paths(task_id: str = "default") -> bool: + env_type = _terminal_env_type_for_task(task_id) try: - from tools.terminal_tool import _CONTAINER_BACKENDS - container_backends = _CONTAINER_BACKENDS + from tools.terminal_tool import _is_container_backend + + return _is_container_backend(env_type) except Exception: - container_backends = _CONTAINER_PATH_BACKENDS_FALLBACK - return _terminal_env_type_for_task(task_id) in container_backends + return env_type in _CONTAINER_PATH_BACKENDS_FALLBACK def _normalize_without_host_deref(path: str | Path | PurePosixPath) -> PurePosixPath: @@ -1530,7 +1534,9 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations: logger.info("Creating new %s environment for task %s...", env_type, task_id[:8]) container_config = None - if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}: + from tools.terminal_tool import _is_container_backend as _is_container + + if _is_container(env_type): container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 5bfa6edba3..5021b8ccc0 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -174,6 +174,20 @@ _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _REMOTE_ENV_BACKENDS = frozenset( {"docker", "singularity", "modal", "ssh", "daytona", "vercel_sandbox"} ) + + +def _is_remote_env_backend(backend: str) -> bool: + """Built-in remote backends plus plugin backends declaring is_remote.""" + if backend in _REMOTE_ENV_BACKENDS: + return True + if not backend or backend == "local": + return False + try: + from agent.terminal_env_registry import provider_flag + + return bool(provider_flag(backend, "is_remote", False)) + except Exception: + return False _secret_capture_callback = None @@ -1900,7 +1914,7 @@ def skill_view( missing_items, setup_help, ) - if backend in _REMOTE_ENV_BACKENDS and setup_note: + if _is_remote_env_backend(backend) and setup_note: setup_note = f"{setup_note} {backend.upper()}-backed skills need these requirements available inside the remote environment as well." if setup_note: result["setup_note"] = setup_note diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index aa0a0094e7..c851da7141 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -1362,22 +1362,41 @@ def _resolve_container_alias(task_id: str) -> str: return key -def _docker_session_isolation_enabled() -> bool: - """True when docker sessions get their OWN containers (issue: stale - workspace mounts leaking between desktop sessions). +def _session_isolation_enabled() -> bool: + """True when non-persistent sandboxes get per-session identities. - Gated on ``terminal.backend: docker`` + ``container_persistent: false``: - a non-persistent sandbox is a statement that state must not survive the - session, so sharing one container across sessions contradicts it. With - ``container_persistent: true`` the documented ONE-long-lived-container - contract is unchanged. + ``container_persistent: false`` is a statement that state must not + survive or be shared across sessions, so sharing one sandbox across + sessions contradicts it (#82731). Backends whose non-persistent mode is + session-scoped: + + - ``docker`` — per-session containers (the original fix). + - plugin backends that declare ``session_isolated_when_nonpersistent`` + (e.g. sandboxes resumed *by name*, where a shared deterministic name + under non-persistent mode would let two independent ephemeral runs + attach one live VM and delete it out from under each other). """ _ensure_terminal_env_bridged() - if os.getenv("TERMINAL_ENV", "local") != "docker": + env_type = os.getenv("TERMINAL_ENV", "local") + if env_type != "docker" and not _plugin_env_flag( + env_type, "session_isolated_when_nonpersistent" + ): return False return os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() not in {"true", "1", "yes"} +def _docker_session_isolation_enabled() -> bool: + """Docker-specific view of :func:`_session_isolation_enabled`. + + Kept separate because several docker-only paths (workspace mount + selection, session-scoped container teardown) key off it; those must + not fire for other backends. + """ + if os.getenv("TERMINAL_ENV", "local") != "docker": + return False + return _session_isolation_enabled() + + _ISOLATION_OVERRIDE_KEYS = frozenset({ "docker_image", "modal_image", "singularity_image", "daytona_image", "env_type", @@ -1429,7 +1448,7 @@ def _resolve_container_task_id(task_id: Optional[str]) -> str: """ if task_id and _has_isolation_overrides(task_id): return task_id - if task_id and _docker_session_isolation_enabled(): + if task_id and _session_isolation_enabled(): return _resolve_container_alias(task_id) # Per-session isolation: when a session key is present (the WebUI streaming # layer sets it per-session, the gateway per-message via contextvars), scope @@ -1554,6 +1573,44 @@ _HOST_CWD_PREFIXES = ("/Users/", "/home/", "C:\\", "C:/") _CONTAINER_BACKENDS = frozenset({"docker", "singularity", "modal", "daytona", "vercel_sandbox"}) +def _plugin_env_flag(env_type: str, attr: str, default=False): + """Classification attribute for a plugin-registered terminal backend. + + Fail-soft: returns *default* when the registry is unavailable, the + backend is unknown, or the provider attribute raises — a misbehaving + plugin must degrade, never take the terminal tool down. + """ + if not env_type or env_type in _CONTAINER_BACKENDS or env_type in {"local", "ssh", "managed_modal"}: + return default + try: + from agent.terminal_env_registry import provider_flag + + return provider_flag(env_type, attr, default) + except Exception: + return default + + +def _is_container_backend(env_type: str) -> bool: + """True when *env_type* behaves like a container/sandbox backend. + + Built-in container backends via ``_CONTAINER_BACKENDS``; plugin-registered + backends via their declarative ``is_container`` flag. + """ + return env_type in _CONTAINER_BACKENDS or _plugin_env_flag(env_type, "is_container") + + +def _get_plugin_env_provider(env_type: str): + """Return the registered plugin provider for *env_type*, or None.""" + if not env_type or env_type in _CONTAINER_BACKENDS or env_type in {"local", "ssh", "managed_modal"}: + return None + try: + from agent.terminal_env_registry import get_provider + + return get_provider(env_type) + except Exception: + return None + + def _is_unusable_container_cwd(cwd: str) -> bool: """Return True if *cwd* is a host/relative path that won't work as the working directory inside a container sandbox. @@ -1637,7 +1694,7 @@ def _get_env_config() -> Dict[str, Any]: env_type = os.getenv("TERMINAL_ENV", "local") mount_docker_cwd = os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower() in {"true", "1", "yes"} - container_backend = env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"} + container_backend = _is_container_backend(env_type) docker_backend = env_type == "docker" # Docker/container-only env vars may be bridged from config.yaml even when @@ -1696,7 +1753,7 @@ def _get_env_config() -> Dict[str, Any]: ): host_cwd = candidate cwd = "/workspace" - elif env_type in _CONTAINER_BACKENDS and cwd: + elif _is_container_backend(env_type) and cwd: # Host paths and relative paths that won't work inside containers if _is_unusable_container_cwd(cwd) and cwd != default_cwd: logger.info("Ignoring TERMINAL_CWD=%r for %s backend " @@ -1999,9 +2056,31 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, ) else: + provider = _get_plugin_env_provider(env_type) + if provider is not None: + env_obj = provider.create_environment( + cwd=cwd, timeout=timeout, task_id=task_id, + image=image, container_config=cc, + ) + # Stamp the backend name so path-resolution and progress surfaces + # can identify plugin backends without class-name sniffing. + try: + env_obj._hermes_backend_name = provider.name.strip().lower() + except AttributeError: + pass # test doubles may reject attributes + return env_obj + try: + from agent.terminal_env_registry import plugin_backend_names + + plugin_names = plugin_backend_names() + except Exception: + plugin_names = [] + extra = ( + ", " + ", ".join(f"'{n}'" for n in plugin_names) if plugin_names else "" + ) raise ValueError( f"Unknown environment type: {env_type}. Use 'local', 'docker', " - f"'singularity', 'modal', 'daytona', 'vercel_sandbox', or 'ssh'" + f"'singularity', 'modal', 'daytona', 'vercel_sandbox', 'ssh'{extra}" ) @@ -2172,7 +2251,7 @@ def ensure_task_env(task_id: Optional[str] = None): ssh_config=_ssh_config_from_config(config) if env_type == "ssh" else None, container_config=( _container_config_from_config(config) - if env_type in _CONTAINER_BACKENDS else None + if _is_container_backend(env_type) else None ), local_config=None, task_id=effective_task_id, @@ -2649,7 +2728,7 @@ def _resolve_command_cwd( recorded = get_session_cwd(session_key) if ( recorded - and env_type in _CONTAINER_BACKENDS + and _is_container_backend(env_type) and _is_unusable_container_cwd(recorded) ): logger.info( @@ -2765,7 +2844,7 @@ def terminal_tool( # Valid in-container override paths (RL/benchmark sandboxes that set # cwd to /workspace, /root, etc.) are absolute non-host paths and pass # through untouched. - if env_type in _CONTAINER_BACKENDS and _is_unusable_container_cwd(cwd): + if _is_container_backend(env_type) and _is_unusable_container_cwd(cwd): remapped = "/workspace" if host_cwd else config["cwd"] if cwd != remapped: logger.info( @@ -2860,7 +2939,7 @@ def terminal_tool( ssh_config = _ssh_config_from_config(config) if env_type == "ssh" else None container_config = ( _container_config_from_config(config) - if env_type in _CONTAINER_BACKENDS else None + if _is_container_backend(env_type) else None ) local_config = None @@ -3871,9 +3950,12 @@ def check_terminal_requirements() -> bool: return get_secret("DAYTONA_API_KEY") is not None else: + provider = _get_plugin_env_provider(env_type) + if provider is not None: + return bool(provider.check_requirements(config)) logger.error( "Unknown TERMINAL_ENV '%s'. Use one of: local, docker, singularity, " - "modal, daytona, vercel_sandbox, ssh.", + "modal, daytona, vercel_sandbox, ssh, or a plugin-registered backend.", env_type, ) return False diff --git a/website/docs/developer-guide/terminal-environment-plugin.md b/website/docs/developer-guide/terminal-environment-plugin.md new file mode 100644 index 0000000000..dd102de46c --- /dev/null +++ b/website/docs/developer-guide/terminal-environment-plugin.md @@ -0,0 +1,140 @@ +# Terminal Environment Provider Plugins + +Hermes runs shell commands through a pluggable set of **terminal backends**. +The built-in backends (local, Docker, Singularity, Modal, Daytona, Vercel +Sandbox, SSH) live in the core repo under `tools/environments/`. Third-party +sandbox vendors integrate as **plugins** instead — a standalone plugin repo +installed under `~/.hermes/plugins/`, registering a backend the user selects +exactly like a built-in one via `terminal.backend` in `config.yaml`. + +This page mirrors the [Browser Provider Plugins](/developer-guide/browser-provider-plugin) +guide — same registration flow, same scope semantics. + +## What a provider controls + +A registered backend automatically participates in every core surface: + +| Surface | Driven by | +|---|---| +| Command dispatch (`terminal`, `execute_code`, file tools) | `create_environment()` | +| `hermes setup` backend picker | `display_name`, `description`, `setup_instructions()`, `post_setup()` | +| Dashboard terminal-backend picker (probe status) | `probe()` | +| `hermes status` / `hermes doctor` | `doctor_checks()` | +| System-prompt environment hints | `is_remote`, `env_description` | +| Dangerous-command approval skipping | `skip_container_guards` | +| Container path/cwd handling | `is_container` | +| Synced cache-file path translation | `cache_path_base` | +| Secret stripping from spawned subprocesses | `strip_env_keys` | +| Per-session sandbox isolation (`container_persistent: false`) | `session_isolated_when_nonpersistent` | + +Declaring these flags on the provider closes the classic "new backend missed +classification site N" bug class — the core consults the registry at each +site instead of a hardcoded list of names. + +## Minimal provider + +```python title="~/.hermes/plugins/acmebox/__init__.py" +from agent.terminal_env_provider import TerminalEnvironmentProvider + + +class AcmeBoxEnvironment: + """Must satisfy the BaseEnvironment duck-typed contract.""" + + def __init__(self, cwd, timeout, task_id): + self.cwd, self.timeout, self.task_id = cwd, timeout, task_id + + def execute(self, command, timeout=None, **kwargs): + ... # run the command in the sandbox + return {"output": "...", "exit_code": 0} + + def cleanup(self): + ... # tear down / detach + + +class AcmeBoxProvider(TerminalEnvironmentProvider): + name = "acmebox" + display_name = "AcmeBox" + is_remote = True # commands don't run on the host + is_container = True # container-style path/cwd semantics + + @property + def description(self): + return "Run commands in an AcmeBox cloud sandbox." + + @property + def cache_path_base(self): + return "~/.hermes" # where synced cache files land, or None + + @property + def strip_env_keys(self): + return frozenset({"ACMEBOX_TOKEN"}) + + def is_available(self): + import importlib.util, os + return ( + importlib.util.find_spec("acmebox") is not None + and bool(os.getenv("ACMEBOX_TOKEN")) + ) + + def create_environment(self, *, cwd, timeout, task_id="default", + image=None, container_config=None, **kwargs): + return AcmeBoxEnvironment(cwd, timeout, task_id) + + +def register(ctx): + ctx.register_terminal_environment_provider(AcmeBoxProvider()) +``` + +```yaml title="~/.hermes/plugins/acmebox/plugin.yaml" +name: acmebox +version: 0.1.0 +description: AcmeBox cloud sandbox terminal backend +kind: backend +``` + +Enable it, select it, run: + +```bash +hermes plugins enable acmebox +hermes config set terminal.backend acmebox +``` + +## Rules + +- **Reserved names.** Registrations that collide with a built-in backend name + (`local`, `docker`, `singularity`, `modal`, `managed_modal`, `daytona`, + `vercel_sandbox`, `ssh`) are rejected. Plugins extend the backend set; they + never shadow in-tree backends. +- **`create_environment` must accept `**kwargs`** and ignore unknown keys — + the forward-compat contract that lets the factory signature evolve without + breaking older plugins. +- **`is_available()` / `probe()` must be cheap.** No network calls — they run + during requirement checks and UI paints. +- **Fail-soft everywhere.** A provider attribute that raises is treated as + its default by the core (e.g. a raising `skip_container_guards` keeps the + approval layer ON). Don't rely on exceptions for control flow. +- **Secrets belong in `strip_env_keys`.** Your vendor token must never be + readable by a model-authored shell command; listing it strips it from every + spawned subprocess unconditionally, like the built-in `MODAL_*` / + `DAYTONA_API_KEY` handling. + +## Environment object contract + +`create_environment()` returns an object satisfying the same duck-typed +interface as `tools.environments.base.BaseEnvironment`: + +- `execute(command, timeout=None, ...)` → `{"output": str, "exit_code": int}` +- `cleanup()` — release resources; called on session teardown / idle reaping +- Optional: persistence hooks mirroring the built-in cloud backends + +Subclassing `BaseEnvironment` is recommended (you inherit the shared file-sync +and background-process plumbing) but not required. + +## Session isolation semantics + +If your sandbox is **resumed by name** (a durable VM the backend re-attaches +to), set `session_isolated_when_nonpersistent = True`. With +`terminal.container_persistent: false`, each session then gets its own +sandbox identity instead of sharing one — without this, two independent +ephemeral runs could attach one live VM and delete it out from under each +other. diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index 14c162f123..d487f2c5fa 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -112,6 +112,7 @@ Every `ctx.*` API below is available inside a plugin's `register(ctx)` function. | Register an image-generation backend | `ctx.register_image_gen_provider(provider)` — see [Image Generation Provider Plugins](/developer-guide/image-gen-provider-plugin) | | Register a video-generation backend | `ctx.register_video_gen_provider(provider)` — see [Video Generation Provider Plugins](/developer-guide/video-gen-provider-plugin) | | Register a context-compression engine | `ctx.register_context_engine(engine)` — see [Context Engine Plugins](/developer-guide/context-engine-plugin) | +| Register a terminal execution backend (cloud sandbox) | `ctx.register_terminal_environment_provider(provider)` — see [Terminal Environment Plugins](/developer-guide/terminal-environment-plugin) | | Route human approval prompts | `ctx.register_approval_transport(name, present_fn)` — see [Approval transports](#approval-transports) | | Register a memory backend | Subclass `MemoryProvider` in `plugins/memory//__init__.py` — see [Memory Provider Plugins](/developer-guide/memory-provider-plugin) (uses a separate discovery system) | | Run a host-owned LLM call | `ctx.llm.complete(...)` / `ctx.llm.complete_structured(...)` — borrow the user's active model + auth for a one-shot completion with optional JSON schema validation. See [Plugin LLM Access](/developer-guide/plugin-llm-access) | diff --git a/website/sidebars.ts b/website/sidebars.ts index 5f337af07b..9f9de703e9 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -790,6 +790,7 @@ const sidebars: SidebarsConfig = { 'developer-guide/video-gen-provider-plugin', 'developer-guide/web-search-provider-plugin', 'developer-guide/browser-provider-plugin', + 'developer-guide/terminal-environment-plugin', ], }, 'developer-guide/creating-skills',