fix(terminal): a plugin backend in the plugin host refuses with the isolation setting

Under plugins.isolation: host a user terminal backend's create_environment()
ran in the host and its environment came back as an Opaque placeholder, so the
first command failed with a backwards 'not available inside the plugin host'
AttributeError and the host was left holding a sandbox nothing could clean up.
Pre-existing for every plugin terminal backend; the Singularity move makes it
reachable for Singularity users who run host isolation.

Creating the environment now refuses before the host starts anything, naming
plugins.isolation: in_process (the Bot Desktop placement refusal's shape).
This commit is contained in:
Teknium committed 2026-10-22 05:26:10 -07:00
1 parent d99af4187b
commit 02f46d9818
5 files changed
+108 -6

No files matched your search

+7
View File
@@ -535,6 +535,13 @@ def _holds_live_handle(value: Any) -> bool:
return found or (isinstance(encoded, dict) and "__opaque__" in encoded)
def is_hosted_object(obj: Any) -> bool:
"""``obj`` is a proxy for a plugin object whose code runs in the plugin host (``plugins.isolation:
host``): its methods run there and anything they return that is not plain data arrives as an
:class:`Opaque` placeholder."""
return "__hermes_plugin_host_ref__" in vars(type(obj))
def _run_on_gateway_loop(coro: Any) -> Any:
"""A facade coroutine (``platform_actions``) uses adapters bound to the gateway's event loop, so
it runs there when a gateway is up; otherwise on a private loop. Always called off-loop (a
@@ -0,0 +1,84 @@
"""A user terminal backend under ``plugins.isolation: host``: the plugin's environment object cannot cross
the host boundary (its methods would run outside the Hermes process's interrupt, activity and terminal-scope
state), so creating it refuses with the setting to change before the host starts a sandbox nobody can use
or clean up. Real plugin, real host process, real loader against a temp HERMES_HOME.
"""
from __future__ import annotations
import hermes_yaml as yaml
import pytest
from hermes_cli import plugins as plugins_mod
from hermes_cli.plugins import PluginManager
BACKEND_PLUGIN = '''
from pathlib import Path
from agent.terminal_env_provider import TerminalEnvironmentProvider
MARKER = Path(__file__).with_name("created")
class Env:
def execute(self, command, **kw):
return {"output": command, "returncode": 0}
def cleanup(self):
pass
class Box(TerminalEnvironmentProvider):
@property
def name(self):
return "hostbox"
def is_available(self):
return True
def create_environment(self, *, cwd, timeout, task_id="default", image=None, container_config=None, **kw):
MARKER.write_text("sandbox started")
return Env()
def register(ctx):
ctx.register_terminal_environment_provider(Box())
'''
def _load(tmp_path, monkeypatch, isolation):
home = tmp_path / ".hermes"
plugin_dir = home / "plugins" / "hostbox"
plugin_dir.mkdir(parents=True)
(plugin_dir / "plugin.yaml").write_text(yaml.safe_dump({"name": "hostbox", "version": "1.0"}), encoding="utf-8")
(plugin_dir / "__init__.py").write_text(BACKEND_PLUGIN, encoding="utf-8")
(home / "config.yaml").write_text(yaml.safe_dump(
{"plugins": {"enabled": ["hostbox"], "isolation": isolation}, "terminal": {"backend": "hostbox"}}),
encoding="utf-8")
empty_bundled = tmp_path / "bundled"
empty_bundled.mkdir()
monkeypatch.setenv("HOME", str(tmp_path / "os-home"))
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(plugins_mod, "get_bundled_plugins_dir", lambda: empty_bundled)
manager = PluginManager()
manager.discover_and_load()
assert manager._plugins["hostbox"].error is None
return manager, plugin_dir / "created"
def test_a_hosted_terminal_backend_refuses_with_the_isolation_setting(tmp_path, monkeypatch):
from tools.terminal_tool_backends import _create_environment
manager, marker = _load(tmp_path, monkeypatch, "host")
try:
with pytest.raises(ValueError, match=r"plugins\.isolation: in_process"):
_create_environment("hostbox", image="", cwd="/", timeout=10)
assert not marker.exists() # no sandbox started in the host
finally:
manager.unload()
manager._plugin_host().shutdown()
def test_the_same_backend_in_process_runs_commands(tmp_path, monkeypatch):
from tools.terminal_tool_backends import _create_environment
manager, marker = _load(tmp_path, monkeypatch, "in_process")
try:
env = _create_environment("hostbox", image="", cwd="/", timeout=10)
assert env.execute("echo hi") == {"output": "echo hi", "returncode": 0}
assert marker.exists()
finally:
manager.unload()
+9
View File
@@ -236,6 +236,15 @@ def _stamp_sandbox_home(env_obj, provider, cwd) -> None:
def _build_plugin_env(*, env_type, image, cwd, timeout, cc, task_id, **_):
provider = _get_plugin_env_provider(env_type)
if provider is not None:
from hermes_cli.plugin_host import is_hosted_object
if is_hosted_object(provider):
# The environment would come back as an opaque placeholder, and its execute() would run outside
# this process's interrupt, activity and terminal-scope state: refuse before the host starts a
# sandbox nothing here could use or clean up.
raise ValueError(
f"terminal.backend is {env_type}, a plugin that runs in the plugin host (plugins.isolation: host), "
f"and the host cannot hand its environment to Hermes, so commands cannot run in it. Set "
f"plugins.isolation: in_process to use this backend.")
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
@@ -207,11 +207,12 @@ the browser keep their scratch files under the environment's
`get_temp_dir()`. An environment that doesn't subclass `BaseEnvironment` and
has no such method gets the same default `BaseEnvironment` uses.
Both `exec_prefix()` and the `~` home resolution read the environment object
itself, so they work only for plugins loaded in-process. Under
`plugins.isolation: host` the environment reaches Hermes as an opaque
placeholder: Bot Desktop refuses a `supports_streams` backend with a message
naming the setting, and `default_cwd = "~"` stays a literal `~`.
A terminal backend runs only in-process: its environment object is what the
terminal tool, file tools, `exec_prefix()` and the `~` home resolution all
drive. Under `plugins.isolation: host` the object cannot cross the host
boundary, so creating the environment refuses with a message naming
`plugins.isolation: in_process` (before the host starts a sandbox), and Bot
Desktop refuses a `supports_streams` backend the same way.
## Session isolation semantics
+2 -1
View File
@@ -887,7 +887,8 @@ What changes in `host` mode:
flight returns a tool error and Hermes restarts the host and reloads its plugins (bounded retries).
- **A few surfaces need in-process code** and fail that plugin with a clear reason instead of loading:
gateway platform adapters (`register_platform`), approval transports, Telegram/platform handlers,
model-provider profiles that build their own SDK client (`create_client`), streaming and websocket
model-provider profiles that build their own SDK client (`create_client`), terminal backends (the
backend's commands fail with this reason; the `singularity` plugin is one), streaming and websocket
dashboard endpoints, and plugins that monkeypatch Hermes modules. Run those with `isolation: in_process`.
**Locking it for a shared deployment.** `plugins.isolation` is ordinary profile config, so whoever can