From 96aafecadd86f514111d6417ea521f2f6f34934b Mon Sep 17 00:00:00 2001 From: Erosika Date: Tue, 30 Jun 2026 19:51:31 +0000 Subject: [PATCH] test(profile): prove isolation fix under the multiplexed gateway, not just desktop The reachability claim that single-process multi-profile leakage is desktop- only is incomplete. gateway/run.py:_profile_runtime_scope shows a SECOND such runtime: the multiplexed gateway (gateway.multiplex_profiles) serves every profile from one process, scoping each inbound turn with the same set_hermes_home_override ContextVar the desktop uses (and the /p// URL prefix). The M1 (import-time path globals) and M2 (thread/executor context) leaks are reachable there identically. - tests/gateway/test_multiplex_credential_isolation.py: add a class driving the skills-dir + cache-dir resolvers and a propagated worker thread under the real _profile_runtime_scope, asserting each resolves the active profile. Sits beside the existing credential-isolation proofs for the same topology. - Correct the inline comments in model_tools/run_agent/async_delegation/ rich_sent_store to name both runtimes (desktop tui_gateway AND the multiplexed gateway) instead of implying desktop is the only surface. (ACP runs one agent per subprocess and the kanban dispatcher Popens 'hermes -p ' children, so neither is an in-process multi-profile surface; desktop + multiplexed gateway are the two confirmed ones.) --- .../test_multiplex_credential_isolation.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/gateway/test_multiplex_credential_isolation.py b/tests/gateway/test_multiplex_credential_isolation.py index 748580197..7659efc39 100644 --- a/tests/gateway/test_multiplex_credential_isolation.py +++ b/tests/gateway/test_multiplex_credential_isolation.py @@ -7,6 +7,8 @@ multiplex mode fails closed instead of leaking. """ import pytest +from pathlib import Path + from agent import secret_scope as ss @@ -86,3 +88,71 @@ class TestMcpInterpolationUsesScope: monkeypatch.setenv("MY_MCP_TOKEN", "env-token") # multiplex off: legacy os.environ resolution assert _interpolate_env_vars("${MY_MCP_TOKEN}") == "env-token" + + +class TestProfilePathResolutionUnderMultiplexScope: + """Profile-scoped paths must follow the per-turn _profile_runtime_scope. + + The multiplexed gateway (gateway.multiplex_profiles) serves every profile + from ONE process, scoping each inbound turn with _profile_runtime_scope — + the same in-process-many-profiles topology as the desktop tui_gateway. The + profile-isolation fixes (per-call path resolution + thread context + propagation) must therefore hold under THIS scope too, not just desktop. + This is the regression guard proving reachability is not desktop-only. + """ + + def _profiles(self, tmp_path): + prof_a = tmp_path / "profA" + prof_b = tmp_path / "profB" + for p in (prof_a, prof_b): + (p / "skills").mkdir(parents=True, exist_ok=True) + (p / "state").mkdir(parents=True, exist_ok=True) + return prof_a, prof_b + + def test_skills_dir_follows_multiplex_scope(self, tmp_path): + from gateway.run import _profile_runtime_scope + import tools.skills_hub as sh + + prof_a, prof_b = self._profiles(tmp_path) + with _profile_runtime_scope(prof_a): + a_seen = Path(sh.SKILLS_DIR) + with _profile_runtime_scope(prof_b): + b_seen = Path(sh.SKILLS_DIR) + + assert a_seen == prof_a / "skills" + assert b_seen == prof_b / "skills" + + def test_cache_dir_follows_multiplex_scope(self, tmp_path): + from gateway.run import _profile_runtime_scope + import gateway.platforms.base as gb + + _prof_a, prof_b = self._profiles(tmp_path) + with _profile_runtime_scope(prof_b): + seen = gb.get_image_cache_dir() + assert str(seen).startswith(str(prof_b)) + + def test_worker_thread_inherits_multiplex_scope(self, tmp_path): + """A wrapped worker spawned inside the scope must see the right profile. + + The _profile_runtime_scope docstring relies on copy_context() carrying + the override into the agent worker thread; this proves the M2 fix + primitive delivers that under the multiplexer's scope. + """ + import threading + + from gateway.run import _profile_runtime_scope + from hermes_constants import get_hermes_home + from tools.thread_context import propagate_context_to_thread + + _prof_a, prof_b = self._profiles(tmp_path) + seen = {} + + def worker(): + seen["home"] = str(get_hermes_home()) + + with _profile_runtime_scope(prof_b): + t = threading.Thread(target=propagate_context_to_thread(worker)) + t.start() + t.join() + + assert seen["home"] == str(prof_b)