fix(gateway): /p/<profile>/ on a non-multiplex gateway fails closed instead of serving the owner profile

A /p/<profile>/ URL prefix on a gateway with multiplex_profiles off was
silently ignored: the request was handled as the gateway-owning profile,
so /p/lokaj/v1/toolsets reported the OWNER's platform_toolsets (and every
other profile-owned config read — skills, capabilities, model options,
agent-run toolset resolution — resolved from the owner too). That is the
exact repro in #91583 defect 2: enabling computer_use with
'hermes -p lokaj tools enable computer_use --platform api_server' showed
enabled in lokaj's config while /p/lokaj/v1/toolsets stayed false, and
enabling it on the owner profile flipped it true.

Per-profile capability isolation is the intended design (ruling on
a different profile's config. Multiplexed gateways were already correct —
the profile-prefix middleware enters _profile_runtime_scope and every
canonical config loader honors the HERMES_HOME override contextvar
(verified empirically for load_config, get_config_path and
_load_gateway_config) — the leak was only the non-multiplex fallthrough.

Fix at the one seam both adapters share: _resolve_request_profile now
rejects (404) a prefix naming any profile other than the one the gateway
actually serves. A self-referential prefix (/p/default/ on the default
gateway, /p/lokaj/ on a gateway launched for lokaj) still falls through
so existing well-formed clients keep working. Same change in the webhook
adapter, which had the identical fallthrough. New shared helper
hermes_cli.profiles.profile_matches_home does the home comparison,
fail-closed.

Tests: tests/gateway/test_multiplex_toolsets_profile_isolation.py —
E2E-style with two real profile homes + config.yamls under a temp
HERMES_HOME, real aiohttp routing through the profile-prefix middleware:
per-profile /p/<x>/v1/toolsets isolation for both owner and secondary
(the #91583 repro asserts computer_use true under /p/lokaj only),
cross-profile key rejection, and the fail-closed non-multiplex prefix
for both adapters. Sabotage-verified: reverting the adapter change fails
the 3 fail-closed tests.

Fixes #91583 (defect 2). Repro and live validation by @kubaboski.
This commit is contained in:
Teknium
2026-08-23 03:33:53 -07:00
parent 265bdcac82
commit 6cb1085d3d
4 changed files with 289 additions and 29 deletions
+32
View File
@@ -387,6 +387,38 @@ def profile_exists(name: str) -> bool:
return get_profile_dir(canon).is_dir()
def profile_matches_home(name: str, home: "Path | None" = None) -> bool:
"""Return True when *name* refers to the profile served from *home*.
``home`` defaults to the process's current Hermes home
(:func:`hermes_constants.get_hermes_home`). Used by single-profile
gateways to decide whether a ``/p/<profile>/`` URL prefix is
self-referential (safe to serve on the bare route) or names a *different*
profile — in which case the request must fail closed rather than silently
resolve config/toolsets from the gateway owner (#91583 defect 2).
Invalid profile names return False (fail closed).
"""
try:
target = get_profile_dir(name)
except Exception:
return False
if home is None:
try:
from hermes_constants import get_hermes_home
home = get_hermes_home()
except Exception:
return False
try:
return (
Path(target).expanduser().resolve(strict=False)
== Path(home).expanduser().resolve(strict=False)
)
except Exception:
return False
def list_profile_names() -> List[str]:
"""Cheap name-only profile listing: ``default`` plus profile dirs.