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()