Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1598,8 +1598,12 @@ def _planned_restart_notification_pending() -> bool:

sys.path.insert(0, str(Path(__file__).parent.parent))

from hermes_constants import get_hermes_home, get_hermes_home_override
_hermes_home = get_hermes_home()
from hermes_constants import get_hermes_home, get_hermes_home_override, get_process_hermes_home
# The PROCESS's own home, never an import-time ContextVar: a multiplexed backend (``hermes serve``)
# first imports this module lazily from a session's agent build, under that session's routed profile
# override, and the import-time config bridge below would then latch the secondary's terminal.* and
# settings into the launch process env for every later launch-profile turn.
_hermes_home = get_process_hermes_home()

# Load ~/.hermes/.env first: user-managed env files must override stale shell exports on restart.
from hermes_cli.env_loader import load_hermes_dotenv
Expand Down
27 changes: 26 additions & 1 deletion tests/gateway/test_config_env_bridge_authority.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
PROJECT_ROOT = Path(__file__).resolve().parents[2]


def _run_gateway_import(hermes_home: Path, initial_env: dict[str, str]) -> dict[str, str]:
def _run_gateway_import(
hermes_home: Path, initial_env: dict[str, str], routed_home: Path | None = None
) -> dict[str, str]:
"""Import gateway.run in a clean subprocess and return the post-import env.

The bridge runs at module-import time, so simply importing is enough
Expand All @@ -33,6 +35,9 @@ def _run_gateway_import(hermes_home: Path, initial_env: dict[str, str]) -> dict[
f"""
import os, sys
sys.path.insert(0, {str(PROJECT_ROOT)!r})
if {str(routed_home or "")!r}:
from hermes_constants import set_hermes_home_override
set_hermes_home_override({str(routed_home or "")!r})

try:
from gateway import run # noqa: F401 — module import triggers bridge
Expand All @@ -50,6 +55,7 @@ def _run_gateway_import(hermes_home: Path, initial_env: dict[str, str]) -> dict[
"HERMES_GATEWAY_BUSY_TEXT_MODE",
"HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT",
"HERMES_TIMEZONE",
"TERMINAL_CWD",
):
v = os.environ.get(k)
if v is not None:
Expand Down Expand Up @@ -218,3 +224,22 @@ def test_env_platform_connect_timeout_wins_over_config(hermes_home: Path) -> Non
)

assert env.get("HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT") == "120"


def test_first_import_under_a_routed_override_bridges_the_process_home(tmp_path: Path) -> None:
"""A multiplexed backend first imports gateway.run lazily inside a routed profile's session;
the import-time bridge must still write the LAUNCH home's config into the process env."""
import yaml

homes = {}
for name, turns in (("launch", 111), ("routed", 222)):
home = tmp_path / name
(home / "work").mkdir(parents=True)
cfg = {"agent": {"max_turns": turns}, "terminal": {"cwd": str(home / "work")}}
(home / "config.yaml").write_text(yaml.safe_dump(cfg), encoding="utf-8")
homes[name] = home

env = _run_gateway_import(homes["launch"], {}, routed_home=homes["routed"])

assert env.get("HERMES_MAX_ITERATIONS") == "111"
assert env.get("TERMINAL_CWD") == str(homes["launch"] / "work")
28 changes: 27 additions & 1 deletion tests/tools/test_terminal_scope_multiplex.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ def test_routed_turn_reads_every_terminal_consumer_from_profile(
assert cfg["cwd"] == str(b_cwd)
assert cfg["docker_volumes"] == []
assert cfg["docker_shared_container_key"] == ""
assert tt._resolve_container_task_id(None) == "default"
# Session-less (cron) work for routed B keys B's own environment, never the launch
# profile's shared "default" one (which carries A's env and terminal policy).
assert tt._resolve_container_task_id(None) == f"home:{os.path.realpath(home)}"
assert gbase._parse_docker_volume_mounts() == []
assert not any(
"alpha-shared" in c for c in gbase._docker_sandbox_dir_candidates("agent:bee:x")
Expand Down Expand Up @@ -143,6 +145,30 @@ def test_profile_omitting_keys_gets_defaults_not_launch_values(tmp_path):
assert json.loads(os.environ["TERMINAL_DOCKER_VOLUMES"]) # A unchanged


def test_persistent_docker_routed_profile_keeps_one_container(tmp_path):
"""Persistent Docker is profile-scoped: a routed profile's session-less (cron) work must key the
SAME container as its session-bound work, and never another profile's."""
import gateway.run as gw
import tools.terminal_tool as tt
from gateway.session_context import clear_session_vars, reset_session_vars, set_session_vars

docker = "terminal:\n backend: docker\n container_persistent: true\n"
keys = {}
for name in ("bee", "wasp"):
home = _profile(tmp_path, name, docker)
with gw._profile_runtime_scope(home):
cron_key = tt._resolve_container_task_id(None)
tokens = set_session_vars(session_key=f"agent:{name}:chat", profile=name)
try:
session_key = tt._resolve_container_task_id(None)
finally:
clear_session_vars(tokens)
reset_session_vars()
assert cron_key == session_key == f"profile:{name}"
keys[name] = cron_key
assert keys["bee"] != keys["wasp"]


def test_malformed_profile_config_refuses_execution(tmp_path):
"""Unresolvable policy → refusal scope; terminal_tool refuses instead of
running under the launch process's ambient policy (fail closed)."""
Expand Down
33 changes: 29 additions & 4 deletions tools/terminal_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,30 @@ def _docker_session_isolation_enabled() -> bool:
return _session_scope().docker_session_isolated


def _routed_home_task_key(profile_scoped: bool) -> Optional[str]:
"""Key for a session-less task serving a routed (non-launch) profile home, else None.

A multiplexed host runs every profile's cron jobs without a session key; collapsing them all onto
``"default"`` made profile B's cron tool calls reuse the environment the launch profile's job
created (its ``.env`` residue, its bridged ``TERMINAL_*``, its shell), so B ran with A's settings.
Persistent Docker keys the profile name exactly like B's session-bound work, so B keeps ONE
container instead of a second one per home path.
"""
from hermes_constants import get_hermes_home_override, profile_name_for_home
from tools.environments.local import _is_routed_home

override = get_hermes_home_override()
if not override or not _is_routed_home(override):
return None
profile = profile_name_for_home(override) if profile_scoped else None
if profile:
return "default" if profile == "default" else f"profile:{profile}"
try:
return f"home:{os.path.realpath(override)}"
except OSError:
return f"home:{override}"


def _resolve_container_task_id(task_id: Optional[str]) -> str:
"""Map a tool-call ``task_id`` to the ``_active_environments`` key. Order matters —
earlier branches are authoritative where they apply:
Expand All @@ -445,9 +469,10 @@ def _resolve_container_task_id(task_id: Optional[str]) -> str:
default-profile gateway sessions share ONE container; other backends key
``session:<key>`` so switching profiles can't reuse another profile's
SSHEnvironment on the wrong host.
4. No session key (CLI): ``shared:<key>`` when opted in (else a CLI run of a
keyed profile would split from its gateway sessions), else ``"default"``,
which subagent ids collapse onto to share the parent's container.
4. No session key (CLI, cron): ``shared:<key>`` when opted in (else a CLI run of a
keyed profile would split from its gateway sessions); a routed multiplexed profile
keys its own home (``profile:<name>`` under persistent Docker, matching branch 3);
else ``"default"``, which subagent ids collapse onto to share the parent's container.
"""
if task_id and _has_isolation_overrides(task_id):
return task_id
Expand All @@ -470,7 +495,7 @@ def _resolve_container_task_id(task_id: Optional[str]) -> str:
# ONE container/cache slot (and sandbox dir) regardless of profile name (#84671).
return f"shared:{shared}"
if not session_key:
return "default"
return _routed_home_task_key(scope.docker_profile_scoped) or "default"
if not scope.docker_profile_scoped:
return f"session:{session_key}"
profile = _current_session_profile() or "default"
Expand Down
Loading