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.
115 lines
4.7 KiB
Python
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
|