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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user