fix(dashboard): merge PUT /api/config with existing on-disk config

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.
This commit is contained in:
blaryx 2026-05-26 15:39:11 +03:00 committed by Teknium
parent ec769e49d2
commit 76af2456a2
2 changed files with 79 additions and 1 deletions

View file

@ -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

View file

@ -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.<custom_key>`` 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()