From f8e7d14b21d21ed447e510cdfa08295af353331b Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 23 Sep 2026 04:10:02 -0700 Subject: [PATCH 1/4] fix(terminal): session-less work for a routed profile keys its own terminal environment A multiplexed host runs every profile's cron jobs without a session key, and _resolve_container_task_id collapsed all of them onto the shared "default" environment. Profile B's cron tool calls therefore reused the LocalEnvironment the launch profile's job created: B's terminal subprocess saw the launch profile's .env residue and bridged TERMINAL_* (TERMINAL_CWD, backend policy). Found by tests/e2e/core/tenancy/test_two_tenant_gateway.py (C7 canary): alpha's cron env snapshot carried default's TENANT_MARKER and TERMINAL_CWD. Session-less work under a routed home override now keys home:; the launch profile and single-profile processes keep "default". The unit test that pinned the shared key for a routed profile is updated. --- tests/tools/test_terminal_scope_multiplex.py | 4 +++- tools/terminal_tool.py | 21 +++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_terminal_scope_multiplex.py b/tests/tools/test_terminal_scope_multiplex.py index 1ad67eda64c34..58ba7c7fcb092 100644 --- a/tests/tools/test_terminal_scope_multiplex.py +++ b/tests/tools/test_terminal_scope_multiplex.py @@ -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") diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 80cfaf9fd0469..998b7fcb22a84 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -430,6 +430,25 @@ def _docker_session_isolation_enabled() -> bool: return _session_scope().docker_session_isolated +def _routed_home_task_key() -> 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. + """ + from hermes_constants import get_hermes_home_override + from tools.environments.local import _is_routed_home + + override = get_hermes_home_override() + if not override or not _is_routed_home(override): + return None + 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: @@ -470,7 +489,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() or "default" if not scope.docker_profile_scoped: return f"session:{session_key}" profile = _current_session_profile() or "default" From c502562780ffe163c2c7abad26dd170350e25eef Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 23 Sep 2026 04:10:02 -0700 Subject: [PATCH 2/4] fix(gateway): anchor gateway.run's import-time home and config bridge on the process home gateway.run bridges config.yaml into os.environ at import, keyed on get_hermes_home(). The Desktop backend (hermes serve) first imports it lazily from a session's agent build (tui_gateway.agent_callbacks._wire_callbacks), under that session's routed profile override, so whichever secondary profile built first latched its terminal.* (TERMINAL_CWD, backend...) and bridged settings into the launch process env for every later launch-profile turn and cron job. Found by tests/e2e/core/tenancy/test_two_tenant_desktop_backend.py (C7 canary): the default profile's cron env snapshot carried alpha's/beta's TERMINAL_CWD. Use get_process_hermes_home(); identical for a standalone gateway. --- gateway/run.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 3f028576ee3b4..65cb6ee8621ec 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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 From de58d469a4a885106fc7e03c3bf459ef411515e9 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:04:30 -0700 Subject: [PATCH 3/4] test(gateway): a first gateway.run import under a routed override bridges the launch home Invariant for the import-time config bridge: a multiplexed backend's first import of gateway.run can happen inside a routed profile's session (hermes serve imports it lazily from an agent build), and the bridge must still write the launch home's agent.max_turns and terminal.cwd into the process env. Red on the previous gateway.run (HERMES_MAX_ITERATIONS came from the routed profile), green with get_process_hermes_home(). --- .../test_config_env_bridge_authority.py | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/gateway/test_config_env_bridge_authority.py b/tests/gateway/test_config_env_bridge_authority.py index f60bc8695aab6..bf4d106d77c29 100644 --- a/tests/gateway/test_config_env_bridge_authority.py +++ b/tests/gateway/test_config_env_bridge_authority.py @@ -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 @@ -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 @@ -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: @@ -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") From 7cd737ebd86a4cee83f90afaa468accc4f88c020 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:20:33 +0000 Subject: [PATCH 4/4] fix(terminal): persistent Docker keys a routed profile's cron work to its profile container Under persistent Docker, profile B's session-bound work keys profile:B but its session-less (cron) work keyed home:, so the same profile ran two long-lived containers. The routed no-session branch now returns the same profile key as branch 3 when Docker is persistent (profile-scoped); other backends keep the per-home key. --- tests/tools/test_terminal_scope_multiplex.py | 24 ++++++++++++++++++++ tools/terminal_tool.py | 18 ++++++++++----- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/tests/tools/test_terminal_scope_multiplex.py b/tests/tools/test_terminal_scope_multiplex.py index 58ba7c7fcb092..271f31070177f 100644 --- a/tests/tools/test_terminal_scope_multiplex.py +++ b/tests/tools/test_terminal_scope_multiplex.py @@ -145,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).""" diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 998b7fcb22a84..efb397fb9e8fe 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -430,19 +430,24 @@ def _docker_session_isolation_enabled() -> bool: return _session_scope().docker_session_isolated -def _routed_home_task_key() -> Optional[str]: +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 + 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: @@ -464,9 +469,10 @@ def _resolve_container_task_id(task_id: Optional[str]) -> str: default-profile gateway sessions share ONE container; other backends key ``session:`` so switching profiles can't reuse another profile's SSHEnvironment on the wrong host. - 4. No session key (CLI): ``shared:`` 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:`` 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:`` 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 @@ -489,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 _routed_home_task_key() or "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"