mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
d99af4187b
commit
02f46d9818
5 files changed
+108
-6
No files matched your search
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user