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 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
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())
name: acmebox
version: 0.1.0
description: AcmeBox cloud sandbox terminal backend
kind: backend
Enable it, select it, run:
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_environmentmust accept**kwargsand 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_guardskeeps 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-inMODAL_*/DAYTONA_API_KEYhandling.
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.