Files
hermes-agent/tests/hermes_cli/test_web_profiles_model_scope.py
teknium1 369d5b07e1 fix(dashboard): map the default root to profile name "default" in _scope_profile_name
Launched from a named profile (HERMES_HOME=<root>/profiles/<name>), the
default root is not the process home, so _scope_profile_name fell through to
path.name and handed the root directory's basename (".hermes" or a custom
root) to _config_profile_scope, which 404s "Profile '<basename>' does not
exist" -- or, if that basename happens to match a real profile, scopes and
writes the wrong one. PUT /api/profiles/default/model and the TUI gateway's
_pin_profile_model both reach this helper.

Return "default" when the path resolves to get_default_hermes_root() before
the path.name fallback; _config_profile_scope("default") already resolves to
the root and builds its secret scope. The second invariant test is reshaped
to the named-profile-launch -> default-target case: 404 before, 200 after,
with the ROOT's .env credential (not the launch profile's) reaching the
validator and only the root config.yaml updated.
2026-09-18 10:32:15 -07:00

115 lines
4.7 KiB
Python

"""``PUT /api/profiles/{name}/model`` must validate under the target profile's secret scope.
``_write_profile_model`` used to enter only the HERMES_HOME override. Once the dashboard
has served a secondary profile (fail-closed multiplexing on), ``switch_model``'s
``key_env`` probe reads through ``get_secret``, which fails closed without an installed
scope — the pick was rejected with "<provider> is not connected" even though the named
profile's ``.env`` held the key (#114676). ``POST /api/model/set`` already binds
``_config_profile_scope``; the profiles router now composes the same scope (home +
secrets) around both the validate and save spans.
"""
import pytest
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient # noqa: E402
from agent import secret_scope # noqa: E402
ACME_YAML = (
"custom_providers:\n"
" - name: acme\n"
" base_url: https://api.acme.test/v1\n"
" key_env: ACME_RELAY_KEY\n"
" models: [acme/mini]\n"
)
@pytest.fixture()
def homes(tmp_path, monkeypatch):
"""A throwaway HERMES_HOME whose named profile carries its own ``.env`` credential.
The process env holds a DIFFERENT value for the same variable: a scoped read must
resolve the profile's key, never the dashboard home's (fail-closed isolation).
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("ACME_RELAY_KEY", "dashboard-home-key")
from hermes_cli import profiles as profiles_mod
from hermes_cli.config import invalidate_env_cache
demo = profiles_mod.get_profile_dir("demo")
demo.mkdir(parents=True, exist_ok=True)
(demo / "config.yaml").write_text(ACME_YAML, encoding="utf-8")
(demo / ".env").write_text("ACME_RELAY_KEY=profile-key\n", encoding="utf-8")
(tmp_path / "config.yaml").write_text(ACME_YAML, encoding="utf-8")
invalidate_env_cache()
return tmp_path, demo
@pytest.fixture()
def probe(monkeypatch):
"""Capture the credential the real ``custom_providers`` resolution handed to the network
validation step (the only stubbed piece); ``key_env`` is read by the production path."""
captured = {}
def _fake_validate(model, provider, api_key=None, base_url=None, **kw):
captured["api_key"] = api_key
return {"accepted": True, "persist": True, "recognized": True, "message": ""}
import hermes_cli.models_validate as mv
monkeypatch.setattr(mv, "validate_requested_model", _fake_validate)
return captured
@pytest.fixture()
def client(homes):
from hermes_cli import web_server
with TestClient(web_server.app, raise_server_exceptions=False) as c:
c.headers["Authorization"] = f"Bearer {web_server._SESSION_TOKEN}"
yield c
def test_model_pick_resolves_key_env_from_profile_scope(client, homes, probe):
"""Multiplexed dashboard: the named profile's ``.env`` authenticates the pick."""
secret_scope.set_multiplex_active(True)
try:
resp = client.put(
"/api/profiles/demo/model", json={"provider": "acme", "model": "acme/mini"}
)
finally:
secret_scope.set_multiplex_active(False)
assert resp.status_code == 200, resp.text
# The profile's key, not the dashboard home's value from the process env.
assert probe["api_key"] == "profile-key"
from hermes_cli.config import load_config
from hermes_cli.web_server_profiles import _hermes_home_scope
with _hermes_home_scope(homes[1]):
assert (load_config().get("model") or {}).get("default") == "acme/mini"
def test_model_pick_for_default_from_named_profile_launch(homes, probe, monkeypatch):
"""Dashboard launched from ``profiles/demo``: targeting ``default`` must scope the ROOT
(name ``default``), not the root directory's basename, which is not a profile name."""
root, demo = homes
monkeypatch.setenv("HERMES_HOME", str(demo))
(root / ".env").write_text("ACME_RELAY_KEY=root-key\n", encoding="utf-8")
from hermes_cli.config import invalidate_env_cache, load_config
from hermes_cli.web_server_profiles import _hermes_home_scope
invalidate_env_cache()
from hermes_cli import web_server
with TestClient(web_server.app, raise_server_exceptions=False) as client:
client.headers["Authorization"] = f"Bearer {web_server._SESSION_TOKEN}"
resp = client.put(
"/api/profiles/default/model", json={"provider": "acme", "model": "acme/mini"}
)
assert resp.status_code == 200, resp.text
assert probe["api_key"] == "root-key" # the root's .env, not the launch profile's
with _hermes_home_scope(root):
assert (load_config().get("model") or {}).get("default") == "acme/mini"
with _hermes_home_scope(demo):
assert (load_config().get("model") or {}).get("default") is None