Files
hermes-agent/tests/tools/test_multiplex_turn_parity.py
T
Teknium 9c9e7ab6e5 fix(multiplex): a served profile's turn sees its own cwd, approvals, redaction and tool policy
Under gateway.multiplex_profiles a secondary profile's turn ran with the LAUNCH
profile's working directory, command allowlist, redact_secrets switch, credential
file mounts, browser engine/headed flags, LSP service, auxiliary-provider health
marks and MCP stderr log, and several TERMINAL_ENV consumers read the process env
instead of the routed profile's terminal scope. A standalone `hermes -p X gateway
run` never behaved that way.

- tools/terminal_scope.py: resolve the terminal.cwd placeholder inside the
  profile scope with the same rule gateway/run.py applies at import (local ->
  $HOME, sandbox default otherwise) so the system prompt, context files and the
  terminal of a routed turn start where the profile's standalone gateway would.
- tools/image_source.py, credential_files.py, image_generation_tool.py,
  skills_tool.py, delegate_tool_progress.py, agent/tool_executor.py: read
  TERMINAL_ENV / TERMINAL_CWD through the terminal scope.
- tools/approval.py (+ approval_floors.py): one permanent allowlist per routed
  profile home; the unscoped module set stays for single-profile processes.
- agent/redact.py: `_redact_enabled()` resolves security.redact_secrets for the
  routed profile (scope .env, then config); launch snapshot kept when unscoped.
- tools/credential_files.py, agent/auxiliary_health.py, agent/lsp/__init__.py,
  tools/browser_tool_cloud.py, tools/mcp_tool_config.py,
  tools/tool_result_storage.py: key process caches by profile home (or bypass
  the slot under an override).

Tests: tests/tools/test_multiplex_turn_parity.py (4, red on base).
Docs: multi-profile-gateways.md isolation table.
2026-09-11 19:39:12 -07:00

146 lines
6.5 KiB
Python

"""Multiplexed-gateway parity for what a TURN sees: a profile served by the default multiplexer
(``_profile_runtime_scope``) must observe the same tool-side policy its standalone gateway
(``HERMES_HOME=<profile>``) would — never the launch profile's values frozen into process caches or
read from the process env.
Every test warms the site under launch home A, then reads under routed profile B with different
config (real temp homes, real config.yaml, real terminal scope; no mocks of the thing under test).
"""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
@pytest.fixture
def two_homes(tmp_path, monkeypatch):
"""Launch home A (HERMES_HOME) and routed profile B, differing in every setting under test."""
a = tmp_path / ".hermes"
b = a / "profiles" / "b"
b.mkdir(parents=True)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(a))
for name, home in (("a", a), ("b", b)):
(home / f"cred_{name}.txt").write_text("x", encoding="utf-8")
(home / "config.yaml").write_text(yaml.safe_dump({
"terminal": {"backend": "local" if name == "a" else "docker",
"credential_files": [f"cred_{name}.txt"]},
"command_allowlist": [f"{name}-only-cmd *"],
"security": {"redact_secrets": name == "b"},
"browser": {"engine": "chrome" if name == "a" else "lightpanda", "headed": name == "b"},
"lsp": {"enabled": name == "b"},
}), encoding="utf-8")
return a, b
def _under(home: Path, fn):
token = set_hermes_home_override(str(home))
try:
return fn()
finally:
reset_hermes_home_override(token)
def test_routed_local_profile_cwd_matches_standalone_gateway(tmp_path, two_homes):
"""A standalone gateway resolves an unset ``terminal.cwd`` on a local backend to ``$HOME`` at
import; the routed profile's terminal scope must yield the same cwd, not the multiplexer's
process cwd — otherwise the system prompt, context files and the terminal all start in
wherever ``hermes gateway`` happened to be launched from."""
from tools.terminal_scope import build_profile_terminal_scope, install_and_reset_profile_terminal_scope
from agent.runtime_cwd import resolve_agent_cwd
a, _ = two_homes
assert build_profile_terminal_scope(a)["TERMINAL_CWD"] == str(tmp_path)
with install_and_reset_profile_terminal_scope(a):
assert resolve_agent_cwd() == tmp_path
# Non-local backends stay unset (sandbox default), exactly like gateway/run.py's placeholder rule.
assert "TERMINAL_CWD" not in build_profile_terminal_scope(two_homes[1])
def test_terminal_backend_consumers_read_the_routed_scope(two_homes):
"""Every ``TERMINAL_ENV`` reader that shapes a turn (image-source locality, credential-file path
translation, image-gen cache base, skill readiness) resolves the ROUTED profile's backend."""
from gateway.run import _profile_runtime_scope
from tools import credential_files, image_generation_tool, image_source
a, b = two_homes
def observe():
return (
image_source._is_local_terminal_backend(),
image_generation_tool._agent_cache_base_for_env(None),
credential_files.to_agent_visible_cache_path("/host/.hermes/x.png", "/root/.hermes"),
sorted(Path(m["host_path"]).name for m in credential_files.get_credential_file_mounts()),
)
with _profile_runtime_scope(a):
assert observe() == (True, None, "/host/.hermes/x.png", ["cred_a.txt"])
with _profile_runtime_scope(b):
local, cache_base, translated, mounts = observe()
assert local is False
assert cache_base == "/root/.hermes"
assert translated != "/host/.hermes/x.png" or mounts == ["cred_b.txt"]
assert mounts == ["cred_b.txt"]
def test_permanent_allowlist_is_per_profile(two_homes):
"""Profile A's ``command_allowlist`` must not pre-approve commands for routed profile B, and
B's own 'always' approvals must not be folded into A's set."""
from tools import approval
a, b = two_homes
approval.load_permanent_allowlist() # launch-profile startup load (A)
assert approval.is_approved("s", "a-only-cmd *")
assert _under(b, lambda: approval.is_approved("s", "a-only-cmd *")) is False
assert _under(b, lambda: approval.is_approved("s", "b-only-cmd *")) is True
_under(b, lambda: approval.approve_permanent("b-always *"))
assert not approval.is_approved("s", "b-always *")
assert _under(a, lambda: approval.is_approved("s", "a-only-cmd *"))
def test_profile_scoped_process_caches_follow_routed_home(two_homes, monkeypatch):
"""Config-derived singletons (redaction switch, aux unhealthy marks, LSP service, browser
engine/headed flags, MCP stderr log path) are keyed by the routed profile home."""
from agent import auxiliary_client, lsp, redact
from tools import browser_tool_cloud, mcp_tool_config
from tools.browser_tool_lifecycle import cleanup_all_browsers
a, b = two_homes
monkeypatch.setattr(redact, "_REDACT_ENABLED", False) # launch profile opted out
redact._REDACT_ENABLED_BY_HOME.clear()
token = "sk-abcdefghijklmnopqrstuvwxyz0123456789"
assert redact.redact_sensitive_text(token) == token
assert _under(b, lambda: redact.redact_sensitive_text(token)) != token
auxiliary_client._reset_aux_unhealthy_cache()
_under(a, lambda: auxiliary_client._mark_provider_unhealthy("openrouter"))
assert _under(a, lambda: auxiliary_client._is_provider_unhealthy("openrouter")) is True
assert _under(b, lambda: auxiliary_client._is_provider_unhealthy("openrouter")) is False
lsp.shutdown_service()
try:
assert _under(a, lsp.get_service) is None
assert _under(b, lsp.get_service) is not None
finally:
lsp.shutdown_service()
cleanup_all_browsers()
assert _under(a, browser_tool_cloud._get_browser_engine) == "chrome"
assert _under(b, browser_tool_cloud._get_browser_engine) == "lightpanda"
assert _under(a, browser_tool_cloud._is_headed_mode) is False
assert _under(b, browser_tool_cloud._is_headed_mode) is True
mcp_tool_config._mcp_stderr_log_fh.clear()
try:
assert Path(_under(a, mcp_tool_config._get_mcp_stderr_log).name).parent == a / "logs"
assert Path(_under(b, mcp_tool_config._get_mcp_stderr_log).name).parent == b / "logs"
finally:
for fh in mcp_tool_config._mcp_stderr_log_fh.values():
fh.close()
mcp_tool_config._mcp_stderr_log_fh.clear()