Files
hermes-agent/tests/tools/test_shared_container_task_id.py
T
carryzuo00 a270c4adea fix(terminal): scope environment cache by session key to prevent cross-profile SSH leakage
_resolve_container_task_id always returned "default", so _active_environments
shared a single SSHEnvironment across all WebUI sessions. When a user switched
from profile A (ssh_host=10.0.0.1) to profile B (ssh_host=10.0.0.2), the new
session found _active_environments["default"] already set to A's SSHEnvironment
and reused it — silently running every command on the wrong remote host.

Fix: when HERMES_SESSION_KEY is present (set per-session by the WebUI streaming
layer and per-message by the gateway via contextvars), return "session:<key>"
as the cache key instead of "default". Each session now owns its own slot in
_active_environments and always creates an environment from its own profile's
TERMINAL_SSH_HOST / TERMINAL_ENV config.

Behaviour unchanged in CLI mode (no HERMES_SESSION_KEY → still "default").
RL/benchmark task overrides (register_task_env_overrides) are unaffected.
Subagent task_ids inside a WebUI session collapse to "session:<key>" so they
continue to share the parent session's container.

Five new regression tests added to test_shared_container_task_id.py.
2026-08-22 15:07:05 +05:30

118 lines
4.3 KiB
Python

"""
Regression tests for the shared-container task_id mapping.
The top-level agent and all delegate_task subagents share a single
terminal sandbox keyed by ``"default"``. ``_resolve_container_task_id``
is the sole gatekeeper for which tool-call task_ids go to the shared
container vs. get their own isolated sandbox. RL / benchmark
environments opt in to isolation by calling
``register_task_env_overrides(task_id, {...})`` before the agent loop;
every other task_id collapses back to ``"default"``.
If you change the collapse logic, update both the helper and these
tests -- see `hermes-agent-dev` skill, "Why do subagents get their own
containers?" section, and the Container lifecycle paragraph under
Docker Backend in ``website/docs/user-guide/configuration.md``.
"""
import pytest
from tools import terminal_tool
@pytest.fixture(autouse=True)
def _clean_overrides():
"""Ensure no stray overrides from other tests leak in."""
before = dict(terminal_tool._task_env_overrides)
terminal_tool._task_env_overrides.clear()
yield
terminal_tool._task_env_overrides.clear()
terminal_tool._task_env_overrides.update(before)
def test_none_task_id_maps_to_default():
assert terminal_tool._resolve_container_task_id(None) == "default"
def test_empty_task_id_maps_to_default():
assert terminal_tool._resolve_container_task_id("") == "default"
def test_cwd_only_override_collapses_to_default():
"""CWD-only overrides (ACP adapter workspace tracking) must NOT trigger
container isolation — they should collapse to the shared 'default'
container so all surfaces (TUI, gateway, dashboard) share one sandbox.
Regression for #37361."""
terminal_tool.register_task_env_overrides(
"acp-session-abc", {"cwd": "/home/user/project"}
)
try:
assert (
terminal_tool._resolve_container_task_id("acp-session-abc")
== "default"
)
finally:
terminal_tool.clear_task_env_overrides("acp-session-abc")
def test_env_type_override_keeps_own_id():
"""env_type is an isolation key — must trigger per-task container."""
terminal_tool.register_task_env_overrides(
"bench-env", {"env_type": "sandbox", "cwd": "/work"}
)
try:
assert (
terminal_tool._resolve_container_task_id("bench-env")
== "bench-env"
)
finally:
terminal_tool.clear_task_env_overrides("bench-env")
# --- Cross-profile SSH-leak isolation (commit e00f940a9, re-applied) ---------
#
# When a session key is present (WebUI/gateway), each session must own its own
# slot in _active_environments so switching from profile A (ssh_host=10.0.0.1)
# to profile B (ssh_host=10.0.0.2) cannot reuse A's SSHEnvironment. Without this
# the shared "default" slot silently runs commands on the wrong remote host.
def test_session_key_scopes_to_its_own_slot(monkeypatch):
monkeypatch.setenv("HERMES_SESSION_KEY", "sess-A")
assert terminal_tool._resolve_container_task_id(None) == "session:sess-A"
def test_distinct_session_keys_get_distinct_slots(monkeypatch):
monkeypatch.setenv("HERMES_SESSION_KEY", "sess-A")
a = terminal_tool._resolve_container_task_id(None)
monkeypatch.setenv("HERMES_SESSION_KEY", "sess-B")
b = terminal_tool._resolve_container_task_id(None)
assert a == "session:sess-A"
assert b == "session:sess-B"
assert a != b
def test_subagent_collapses_onto_parent_session(monkeypatch):
# Subagents inherit the parent's session key, so they share the parent's
# container (the #16177 intent) rather than a global "default".
monkeypatch.setenv("HERMES_SESSION_KEY", "sess-A")
assert (
terminal_tool._resolve_container_task_id("subagent-3-cafef00d")
== "session:sess-A"
)
def test_rl_override_wins_over_session_key(monkeypatch):
monkeypatch.setenv("HERMES_SESSION_KEY", "sess-A")
terminal_tool.register_task_env_overrides("tb2-z", {"docker_image": "z:1"})
try:
assert terminal_tool._resolve_container_task_id("tb2-z") == "tb2-z"
finally:
terminal_tool.clear_task_env_overrides("tb2-z")
def test_no_session_key_still_defaults(monkeypatch):
# CLI mode: no session key -> unchanged "default" behaviour.
monkeypatch.delenv("HERMES_SESSION_KEY", raising=False)
assert terminal_tool._resolve_container_task_id(None) == "default"