diff --git a/gateway/run.py b/gateway/run.py index 11eff2fd9df..2fcc67c6760 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -16658,10 +16658,32 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew Prefers the profile the source was routed to (``source.profile`` — set by the /p// URL prefix or a per-credential adapter), falling back to the active profile (the multiplexer's own home). + + A routed profile whose directory does not exist (e.g. a relay binding + or URL prefix that names a profile the operator has since deleted) must + NOT scope the turn into a missing HERMES_HOME — config, skills, and the + secret scope would all resolve against a nonexistent dir. In that case + we log and fall back to the active/default home, the same clean fallback + an empty routed profile already gets. ``get_profile_dir`` returns a path + regardless of existence and does not raise for a valid-but-absent name, + so an explicit ``profile_exists`` check is required here. """ - from hermes_cli.profiles import get_active_profile_name, get_profile_dir + from hermes_cli.profiles import ( + get_active_profile_name, + get_profile_dir, + profile_exists, + ) try: - name = (source.profile or "").strip() or get_active_profile_name() or "default" + routed = (source.profile or "").strip() + if routed and not profile_exists(routed): + logger.warning( + "Routed profile %r has no profile directory; falling back " + "to the active/default home. (Stale relay/URL routing to a " + "deleted profile?)", + routed, + ) + routed = "" + name = routed or get_active_profile_name() or "default" return get_profile_dir(name) except Exception: from hermes_constants import get_hermes_home diff --git a/tests/gateway/test_multiplex_credential_isolation.py b/tests/gateway/test_multiplex_credential_isolation.py index 7659efc3984..b85249055a0 100644 --- a/tests/gateway/test_multiplex_credential_isolation.py +++ b/tests/gateway/test_multiplex_credential_isolation.py @@ -156,3 +156,55 @@ class TestProfilePathResolutionUnderMultiplexScope: t.join() assert seen["home"] == str(prof_b) + + +class TestResolveProfileHomeMissingProfileFallback: + """_resolve_profile_home_for_source must not scope a turn into a missing home. + + A routed source.profile that names a profile whose directory has been + deleted (stale relay binding / URL prefix) must fall back to the active/ + default home rather than returning /profiles/ — a nonexistent + HERMES_HOME that would make config/skills/secret-scope resolve against a + missing dir. get_profile_dir returns a path regardless of existence and does + NOT raise for a valid-but-absent name, so the resolver needs an explicit + profile_exists() check. + """ + + def _runner(self): + from gateway.run import GatewayRunner + return GatewayRunner.__new__(GatewayRunner) + + def _source(self, profile): + from types import SimpleNamespace + return SimpleNamespace(profile=profile) + + def test_existing_routed_profile_resolves_to_its_dir(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "profiles" / "coder").mkdir(parents=True) + runner = self._runner() + home = runner._resolve_profile_home_for_source(self._source("coder")) + assert home == tmp_path / "profiles" / "coder" + + def test_missing_routed_profile_falls_back_to_default(self, tmp_path, monkeypatch): + # HERMES_HOME is the default (pre-profile) home; no profiles/ghost dir. + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + runner = self._runner() + home = runner._resolve_profile_home_for_source(self._source("ghost")) + # Falls back to the active/default home, NOT tmp_path/profiles/ghost. + assert home != tmp_path / "profiles" / "ghost" + assert home == tmp_path + + def test_empty_routed_profile_uses_active_default(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + runner = self._runner() + for empty in (None, "", " "): + home = runner._resolve_profile_home_for_source(self._source(empty)) + assert home == tmp_path + + def test_missing_profile_does_not_raise(self, tmp_path, monkeypatch): + # The whole resolver is wrapped in try/except → get_hermes_home; the + # fallback must land on a real dir, never propagate an exception. + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + runner = self._runner() + home = runner._resolve_profile_home_for_source(self._source("nope")) + assert home.exists()