diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index ec1a4adbf39..49cd5808031 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -56,6 +56,7 @@ from hermes_cli.config import ( get_hermes_home, load_config, load_env, + read_raw_config, save_config, save_env_value, remove_env_value, @@ -65,6 +66,7 @@ from hermes_cli.config import ( recommended_update_command_for_method, redact_key, write_platform_config_field, + _deep_merge, ) from hermes_cli.memory_providers import ( MemoryProvider, @@ -4324,7 +4326,15 @@ def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]: async def update_config(body: ConfigUpdate, profile: Optional[str] = None): try: with _profile_scope(body.profile or profile): - save_config(_denormalize_config_from_web(body.config)) + # The dashboard form is schema-driven (see CONFIG_SCHEMA). Any root + # key absent from the schema — most visibly ``custom_providers``, but + # also ``agent.personalities``, ``terminal.lifetime_seconds``, etc. — + # is not sent in the PUT body. A full-replace save would silently + # drop those keys. Deep-merge incoming over what's on disk so the + # frontend can only overwrite what it explicitly sends. + existing = read_raw_config() + incoming = _denormalize_config_from_web(body.config) + save_config(_deep_merge(existing, incoming)) return {"ok": True} except HTTPException: raise diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index c92599bd74a..6ca16a9abb9 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2917,6 +2917,74 @@ class TestConfigRoundTrip: web_config["agent"]["max_turns"] = original_turns self.client.put("/api/config", json={"config": web_config}) + def test_round_trip_preserves_custom_providers(self): + """``custom_providers`` is not in the dashboard schema, so the + frontend never sends it in PUT bodies. Saving must still preserve + it on disk — otherwise every dashboard click that saves silently + wipes the user's custom endpoints.""" + from hermes_cli.config import load_config, save_config + + save_config({ + "model": {"default": "test/model", "provider": "custom:myprov"}, + "custom_providers": [ + { + "name": "myprov", + "base_url": "https://example.invalid/v1", + "key_env": "MYPROV_API_KEY", + "api_mode": "chat_completions", + "model": "test/model", + }, + ], + }) + + # Frontend behaviour: GET full config, then PUT without keys the + # schema doesn't know about (custom_providers is the prime example). + web_config = self.client.get("/api/config").json() + web_config.pop("custom_providers", None) + resp = self.client.put("/api/config", json={"config": web_config}) + assert resp.status_code == 200 + + after = load_config() + cps = after.get("custom_providers") + assert isinstance(cps, list) and len(cps) == 1, \ + f"custom_providers wiped by lossy PUT: {cps!r}" + assert cps[0].get("name") == "myprov" + assert cps[0].get("base_url") == "https://example.invalid/v1" + + def test_round_trip_preserves_schema_invisible_nested_keys(self): + """Nested keys that aren't in CONFIG_SCHEMA must also survive a + round-trip. Deep-merge is required — a shallow merge would drop + ``agent.`` when the frontend sends a partial ``agent`` + dict containing only schema-known sub-fields.""" + from hermes_cli.config import load_config, read_raw_config, save_config + + # Seed config with a key under `agent` that isn't in the schema. + # Use a sentinel name to avoid colliding with future schema fields. + save_config({ + "agent": { + "max_turns": 50, + "x_dashboard_invisible_test_key": {"nested": "value"}, + }, + }) + + # PUT only schema-known agent fields, exactly like the dashboard. + web_config = self.client.get("/api/config").json() + web_config.setdefault("agent", {}) + web_config["agent"]["max_turns"] = 75 + # Strip our sentinel so we're sending what the schema-driven form + # would send. + web_config["agent"].pop("x_dashboard_invisible_test_key", None) + + resp = self.client.put("/api/config", json={"config": web_config}) + assert resp.status_code == 200 + + on_disk = read_raw_config() + assert on_disk.get("agent", {}).get("max_turns") == 75 + assert on_disk.get("agent", {}).get("x_dashboard_invisible_test_key") \ + == {"nested": "value"}, \ + "Shallow-merge regression: agent.x_dashboard_invisible_test_key " \ + "was wiped when the frontend sent a partial agent dict." + def test_schema_types_match_config_values(self): """Every schema field should have a matching-type value in the config.""" config = self.client.get("/api/config").json()