From 76af2456a2b9cd493cdb14d46f7db34d8c0b7f9d Mon Sep 17 00:00:00 2001 From: blaryx Date: Tue, 26 May 2026 15:39:11 +0300 Subject: [PATCH] fix(dashboard): merge PUT /api/config with existing on-disk config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard form is built from CONFIG_SCHEMA, which doesn't enumerate every root-level key the YAML supports. Most visibly, `custom_providers` is in `_KNOWN_ROOT_KEYS` but is absent from the schema — so the frontend never sends it in the PUT body. The previous full-replace save() then silently wiped the key from disk every time the user clicked anything that triggered a save. Other casualties (less visible because defaults re-mask them on load) include `agent.personalities`, `agent.reasoning_effort`, `terminal.lifetime_seconds`, etc. Fix: read the raw on-disk config and deep-merge the incoming PUT body on top of it before saving. The frontend can only overwrite what it explicitly sends; everything else is preserved verbatim. Reuses the existing `_deep_merge` helper from `hermes_cli.config`. Tests: - `test_round_trip_preserves_custom_providers` exercises the exact bug: seed config with custom_providers, GET → drop the key → PUT, assert it's still on disk. - `test_round_trip_preserves_schema_invisible_nested_keys` covers the shallow-vs-deep-merge case for nested dicts under `agent` etc. Both fail on current main; both pass with this patch. --- hermes_cli/web_server.py | 12 ++++- tests/hermes_cli/test_web_server.py | 68 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) 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()