fix(gateway): fall back to default home when routed profile does not exist

_resolve_profile_home_for_source resolved source.profile via get_profile_dir()
without checking the profile directory exists. get_profile_dir() returns a path
regardless of existence and does NOT raise for a valid-but-absent name, so a
routed profile that names a deleted profile (a stale relay binding or a
/p/<profile>/ URL prefix pointing at a since-removed profile) silently scoped the
whole turn into <root>/profiles/<ghost> — a nonexistent HERMES_HOME. Config,
skills, SOUL, and the fail-closed secret scope all then resolve against a missing
directory instead of cleanly falling back.

Add an explicit profile_exists() check on the ROUTED name only: when the routed
profile has no directory, log a warning and fall back to the active/default home
(the same clean fallback an empty routed profile already gets). The active/
default fallback name is trusted and not existence-checked.

This is a latent bug independent of any one caller — it affects the existing
/p/<profile>/ URL prefix today, and hardens the path ahead of per-scope profile
routing (Team Gateway) populating source.profile from stored state that can drift
from the agent's live profile list.

Tests: existing routed profile → its dir; missing routed → active/default (NOT
profiles/ghost); empty/None/whitespace → active/default; never raises. Proven
fail-without-fix (reverting the check fails exactly the two missing-profile
cases).
This commit is contained in:
Ben 2026-07-08 15:01:28 +10:00
parent 4d7f8ade3e
commit a212b37eff
2 changed files with 76 additions and 2 deletions

View file

@ -16658,10 +16658,32 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
Prefers the profile the source was routed to (``source.profile`` set
by the /p/<profile>/ 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

View file

@ -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 <root>/profiles/<ghost> 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()