From c7e09f25716764b2e4dacf518f1c553a497c15d7 Mon Sep 17 00:00:00 2001 From: Erosika Date: Thu, 9 Jul 2026 17:01:35 -0400 Subject: [PATCH] fix(desktop): restore curated declared schema for the provider panel The desktop provider panel previously rendered the curated declarations from hermes_cli/memory_providers.py: five hindsight fields, and no panel at all for undeclared providers like honcho (OAuth connect only). The dashboard provider-switching rework re-pointed the shared config route at raw plugin schemas, so the desktop began dumping every internal field (35 for hindsight) and grew a bespoke honcho panel. Serve both surfaces from the same route: ?surface=declared returns the curated schema (empty for undeclared providers) with the original config-file + env-store write semantics; the dashboard keeps the raw plugin schema unchanged. The desktop client opts into declared. --- apps/desktop/src/hermes.ts | 5 +- hermes_cli/web_server.py | 143 +++++++++++++++++++++++++++- tests/hermes_cli/test_web_server.py | 49 ++++++++++ 3 files changed, 193 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index a902e4b53e1c..ddaabd15cedb 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -413,15 +413,16 @@ export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: bool }) } +// surface=declared serves the curated desktop schema; the dashboard consumes the raw plugin schema. export function getMemoryProviderConfig(provider: string): Promise { return window.hermesDesktop.api({ - path: `/api/memory/providers/${encodeURIComponent(provider)}/config` + path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared` }) } export function saveMemoryProviderConfig(provider: string, values: Record): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ - path: `/api/memory/providers/${encodeURIComponent(provider)}/config`, + path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`, method: 'PUT', body: { values } }) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index fed4b066d290..a6bb5ba1a6ea 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -84,6 +84,11 @@ from gateway.status import ( parse_active_agents, read_runtime_status, ) +from hermes_cli.memory_providers import ( + MemoryProvider as DeclaredMemoryProvider, + ProviderField as DeclaredProviderField, + get_memory_provider as get_declared_memory_provider, +) from utils import env_var_enabled try: @@ -5169,9 +5174,129 @@ def _require_valid_memory_provider_name(name: str) -> None: raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}") +# --------------------------------------------------------------------------- +# Declared surface — curated desktop schema from hermes_cli.memory_providers. +# The desktop panel requests ?surface=declared; the dashboard keeps the raw +# plugin schema. Providers without a declaration render no desktop panel. +# --------------------------------------------------------------------------- + +def _declared_provider_file_path(provider: DeclaredMemoryProvider) -> Path: + return get_hermes_home() / provider.name / "config.json" + + +def _read_declared_provider_file(provider: DeclaredMemoryProvider) -> Dict[str, Any]: + return _read_json_file(_declared_provider_file_path(provider)) + + +def _declared_read_field_value(field: DeclaredProviderField, data: Dict[str, Any]) -> str: + for source_key in (field.key, *field.aliases): + value = data.get(source_key) + if value: + return str(value) + + env_on_disk = load_env() + for env_key in field.env_fallbacks: + value = env_on_disk.get(env_key) + if value: + return str(value) + + return field.default + + +def _declared_field_is_set(field: DeclaredProviderField, data: Dict[str, Any]) -> bool: + env_on_disk = load_env() + for env_key in (field.env_key, *field.env_fallbacks): + if env_key and env_on_disk.get(env_key): + return True + return any(data.get(source_key) for source_key in (field.key, *field.aliases)) + + +def _declared_provider_payload(provider: DeclaredMemoryProvider) -> Dict[str, Any]: + data = _read_declared_provider_file(provider) + fields: List[Dict[str, Any]] = [] + + for field in provider.fields: + entry: Dict[str, Any] = { + "key": field.key, + "label": field.label, + "kind": field.kind, + "description": field.description, + "placeholder": field.placeholder, + "options": [ + {"value": opt.value, "label": opt.label, "description": opt.description} + for opt in field.options + ], + } + + if field.is_secret: + # Secrets are write-only over the API; only expose whether one is set. + entry["value"] = "" + entry["is_set"] = _declared_field_is_set(field, data) + else: + value = _declared_read_field_value(field, data) + if field.kind == "select" and value not in field.allowed_values(): + value = field.default + entry["value"] = value + entry["is_set"] = bool(value) + + fields.append(entry) + + return {"name": provider.name, "label": provider.label, "fields": fields} + + +def _coerce_declared_field_value(field: DeclaredProviderField, raw: str) -> str: + value = (raw or "").strip() + if field.kind == "select": + if not value: + value = field.default + if value not in field.allowed_values(): + raise ValueError(f"Invalid value for '{field.key}'") + return value + return value or field.default + + +def _update_declared_provider_config(provider: DeclaredMemoryProvider, values: Dict[str, Any]) -> None: + existing = _read_declared_provider_file(provider) + json_values: Dict[str, Any] = {} + secrets: Dict[str, str] = {} + + for field in provider.fields: + if field.is_secret: + submitted = str(values.get(field.key) or "").strip() + if submitted and field.env_key: + secrets[field.env_key] = submitted + continue + + raw = ( + values[field.key] + if field.key in values + else str(existing.get(field.key, field.default)) + ) + json_values[field.key] = _coerce_declared_field_value(field, str(raw)) + + path = _declared_provider_file_path(provider) + path.parent.mkdir(parents=True, exist_ok=True) + existing.update(json_values) + from utils import atomic_json_write + + atomic_json_write(path, existing, mode=0o600) + + for env_key, secret in secrets.items(): + save_env_value(env_key, secret) + + @app.get("/api/memory/providers/{name}/config") -async def get_memory_provider_config(name: str): +async def get_memory_provider_config(name: str, surface: Optional[str] = None): _require_valid_memory_provider_name(name) + + if surface == "declared": + declared = get_declared_memory_provider(name) + if declared is None: + # Undeclared providers (e.g. builtin, honcho) have no desktop + # config surface; the generic panel renders nothing. + return {"name": name, "label": name, "fields": []} + return _declared_provider_payload(declared) + provider = _load_memory_provider(name) if provider is None: # Undeclared providers (e.g. builtin) have no config surface. Return an @@ -5202,8 +5327,22 @@ async def setup_memory_provider(name: str, body: MemoryProviderSetupRequest): @app.put("/api/memory/providers/{name}/config") -async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate): +async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate, surface: Optional[str] = None): _require_valid_memory_provider_name(name) + + if surface == "declared": + declared = get_declared_memory_provider(name) + if declared is None: + raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}") + try: + _update_declared_provider_config(declared, body.values or {}) + return {"ok": True} + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception: + _log.exception("PUT /api/memory/providers/%s/config (declared) failed", name) + raise HTTPException(status_code=500, detail="Internal server error") + provider = _load_memory_provider(name) if provider is None: raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}") diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 19d379dfcd0d..fc42fcc28ca3 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -491,6 +491,55 @@ class TestWebServerEndpoints: assert fields["api_key"]["url"] == "https://app.honcho.dev" assert fields["baseUrl"]["kind"] == "text" + def test_declared_surface_serves_curated_hindsight_schema(self): + resp = self.client.get("/api/memory/providers/hindsight/config?surface=declared") + + assert resp.status_code == 200 + data = resp.json() + fields = self._provider_field_map(data) + assert set(fields) == {"mode", "api_key", "api_url", "bank_id", "recall_budget"} + assert fields["mode"]["kind"] == "select" + assert fields["api_key"]["kind"] == "secret" + + def test_declared_surface_hides_undeclared_providers(self): + resp = self.client.get("/api/memory/providers/honcho/config?surface=declared") + + assert resp.status_code == 200 + assert resp.json()["fields"] == [] + + def test_declared_surface_put_writes_config_and_secret(self): + from hermes_constants import get_hermes_home + from hermes_cli.config import load_env + + resp = self.client.put( + "/api/memory/providers/hindsight/config?surface=declared", + json={ + "values": { + "mode": "local_external", + "api_url": "http://localhost:8888", + "api_key": "hs-declared-key", + } + }, + ) + + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + assert load_env()["HINDSIGHT_API_KEY"] == "hs-declared-key" + + config_path = get_hermes_home() / "hindsight" / "config.json" + provider_config = json.loads(config_path.read_text(encoding="utf-8")) + assert provider_config["mode"] == "local_external" + assert provider_config["api_url"] == "http://localhost:8888" + assert "api_key" not in provider_config + + def test_declared_surface_put_rejects_undeclared_provider(self): + resp = self.client.put( + "/api/memory/providers/honcho/config?surface=declared", + json={"values": {"api_key": "x"}}, + ) + + assert resp.status_code == 404 + def test_all_listed_memory_provider_configs_fetch(self): resp = self.client.get("/api/memory")