fix(memory): profile-scope the provider config endpoints

The settings page follows the desktop's active-profile switcher, but the
provider config calls didn't: no profileScoped() on the client and no
profile param on the backend, so a multi-profile desktop edited the serving
process's config while every surrounding card showed the selected profile's.

Accept ?profile= on both endpoints and resolve inside _profile_scope (the
skills/toolsets contract), spread profileScoped() into the two client calls,
and key the schema cache on the resolved config_schema.py path instead of
the provider name — user-installed plugins are per-profile, so one profile's
lookup must never answer for another's.
This commit is contained in:
Erosika 2026-07-07 10:53:43 -04:00
parent 703305751d
commit 76c063b3d9
6 changed files with 113 additions and 30 deletions

View file

@ -3,8 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
checkHermesUpdate,
getActionStatus,
getMemoryProviderConfig,
getStatus,
restartGateway,
saveMemoryProviderConfig,
setApiRequestProfile,
updateHermes
} from './hermes'
@ -33,6 +35,17 @@ describe('backend action helpers are profile-scoped', () => {
expect(lastProfile()).toBeUndefined()
})
it('forwards the active profile to memory provider config calls', () => {
setApiRequestProfile('coder')
void getMemoryProviderConfig('honcho')
void saveMemoryProviderConfig('honcho', { workspace: 'w' })
for (const call of api.mock.calls) {
expect(call[0].profile).toBe('coder')
}
})
it('forwards the active profile to every backend action', () => {
setApiRequestProfile('coder')

View file

@ -369,12 +369,14 @@ export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: bool
export function getMemoryProviderConfig(provider: string): Promise<MemoryProviderConfig> {
return window.hermesDesktop.api<MemoryProviderConfig>({
...profileScoped(),
path: `/api/memory/providers/${encodeURIComponent(provider)}/config`
})
}
export function saveMemoryProviderConfig(provider: string, values: Record<string, string>): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
path: `/api/memory/providers/${encodeURIComponent(provider)}/config`,
method: 'PUT',
body: { values }

View file

@ -4177,13 +4177,17 @@ def _write_provider_honcho(provider: ProviderConfigSchema, values: Dict[str, str
@app.get("/api/memory/providers/{name}/config")
async def get_memory_provider_config(name: str):
provider = await asyncio.to_thread(get_provider_config_schema, name)
if provider is None:
# Undeclared providers (e.g. builtin) have no config surface. Return an
# empty schema so the generic panel simply renders nothing.
return {"name": name, "label": name, "docs_url": "", "fields": []}
return await asyncio.to_thread(_memory_provider_payload, provider)
async def get_memory_provider_config(name: str, profile: Optional[str] = None):
def _run():
with _profile_scope(profile):
provider = get_provider_config_schema(name)
if provider is None:
# Undeclared providers (e.g. builtin) have no config surface. Return
# an empty schema so the generic panel simply renders nothing.
return {"name": name, "label": name, "docs_url": "", "fields": []}
return _memory_provider_payload(provider)
return await asyncio.to_thread(_run)
def _update_memory_provider_config(provider: ProviderConfigSchema, values: Dict[str, str]) -> None:
@ -4203,13 +4207,16 @@ def _update_memory_provider_config(provider: ProviderConfigSchema, values: Dict[
@app.put("/api/memory/providers/{name}/config")
async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate):
provider = await asyncio.to_thread(get_provider_config_schema, name)
if provider is None:
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate, profile: Optional[str] = None):
def _run():
with _profile_scope(profile):
provider = get_provider_config_schema(name)
if provider is None:
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
_update_memory_provider_config(provider, body.values or {})
try:
await asyncio.to_thread(_update_memory_provider_config, provider, body.values or {})
await asyncio.to_thread(_run)
return {"ok": True}
except HTTPException:
raise

View file

@ -104,34 +104,39 @@ class ProviderConfigSchema:
return tuple(f for f in self.fields if f.inline)
_SCHEMA_CACHE: dict[str, ProviderConfigSchema | None] = {}
_SCHEMA_CACHE: dict[str, ProviderConfigSchema] = {}
def get_provider_config_schema(name: str) -> ProviderConfigSchema | None:
"""Return the ``CONFIG_SCHEMA`` declared by the provider plugin ``name``.
Providers without a ``config_schema.py`` (e.g. ``builtin``) return ``None``
and simply render no config panel.
and simply render no config panel. The cache keys on the resolved schema
file, not the name: user-installed plugins are per-profile, so one
profile's lookup must never answer for another's.
"""
if name in _SCHEMA_CACHE:
return _SCHEMA_CACHE[name]
from plugins.memory import find_provider_dir
schema: ProviderConfigSchema | None = None
provider_dir = find_provider_dir(name)
path = provider_dir / "config_schema.py" if provider_dir else None
if path is not None and path.is_file():
try:
spec = importlib.util.spec_from_file_location(f"_hermes_memory_config_schema.{name}", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
schema = getattr(module, "CONFIG_SCHEMA", None)
except Exception:
# Never cache a failed load: it would pin an empty panel until restart.
_log.exception("failed to load config schema for memory provider %r", name)
return None
if path is None or not path.is_file():
return None
_SCHEMA_CACHE[name] = schema
key = str(path)
if key in _SCHEMA_CACHE:
return _SCHEMA_CACHE[key]
try:
spec = importlib.util.spec_from_file_location(f"_hermes_memory_config_schema.{name}", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
schema = getattr(module, "CONFIG_SCHEMA", None)
except Exception:
# Never cache a failed load: it would pin an empty panel until restart.
_log.exception("failed to load config schema for memory provider %r", name)
return None
if schema is not None:
_SCHEMA_CACHE[key] = schema
return schema

View file

@ -771,6 +771,38 @@ class TestWebServerEndpoints:
assert resp.status_code == 200
assert json.loads(path.read_text(encoding="utf-8"))["hosts"]["hermes"]["workspace"] == "myws"
def test_memory_provider_config_honors_profile_param(self, monkeypatch, tmp_path):
# A ?profile= save must land in that profile's config, not the serving
# process's — same contract as the skills/toolsets endpoints.
monkeypatch.setenv("HOME", str(tmp_path))
from hermes_constants import get_hermes_home
from hermes_cli.profiles import get_profile_dir
self._seed_local_honcho()
worker_home = get_profile_dir("worker")
worker_home.mkdir(parents=True, exist_ok=True)
worker_cfg = worker_home / "honcho.json"
worker_cfg.write_text(json.dumps({"hosts": {"hermes_worker": {"workspace": "kept"}}}), encoding="utf-8")
resp = self.client.put(
"/api/memory/providers/honcho/config?profile=worker",
json={"values": {"peerName": "eri"}},
)
assert resp.status_code == 200
worker_hosts = json.loads(worker_cfg.read_text(encoding="utf-8"))["hosts"]
host_block = next(iter(worker_hosts.values()))
assert host_block["peerName"] == "eri"
# The serving process's own config is untouched.
own = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8"))
assert "peerName" not in json.dumps(own)
fields = self._provider_field_map(
self.client.get("/api/memory/providers/honcho/config?profile=worker").json()
)
assert fields["peerName"]["value"] == "eri"
def test_put_honcho_rejects_malformed_number_and_json(self, monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))

View file

@ -17,6 +17,30 @@ def test_schemas_are_cached_per_provider():
assert get_provider_config_schema("honcho") is get_provider_config_schema("honcho")
def test_cache_keys_on_schema_path_not_name(monkeypatch, tmp_path):
# User-installed plugins are per-profile; two profiles' plugins sharing a
# name must not answer for each other.
import plugins.memory as memory
schemas = {}
for label in ("A", "B"):
plugin_dir = tmp_path / label / "custom"
plugin_dir.mkdir(parents=True)
(plugin_dir / "config_schema.py").write_text(
"from plugins.memory.config_schema import ProviderConfigSchema\n"
f'CONFIG_SCHEMA = ProviderConfigSchema(name="custom", label="{label}")\n',
encoding="utf-8",
)
schemas[label] = plugin_dir
monkeypatch.setattr(config_schema, "_SCHEMA_CACHE", {})
monkeypatch.setattr(memory, "find_provider_dir", lambda name: schemas["A"])
assert get_provider_config_schema("custom").label == "A"
monkeypatch.setattr(memory, "find_provider_dir", lambda name: schemas["B"])
assert get_provider_config_schema("custom").label == "B"
def test_broken_schema_is_not_cached(monkeypatch, tmp_path):
# A load failure must retry on the next request, not pin an empty panel.
broken_dir = tmp_path / "broken"
@ -30,7 +54,7 @@ def test_broken_schema_is_not_cached(monkeypatch, tmp_path):
monkeypatch.setattr(memory, "find_provider_dir", lambda name: broken_dir)
assert get_provider_config_schema("broken") is None
assert "broken" not in config_schema._SCHEMA_CACHE
assert not config_schema._SCHEMA_CACHE
schema_file.write_text(
"from plugins.memory.config_schema import ProviderConfigSchema\n"