diff --git a/apps/desktop/src/hermes-profile-scope.test.ts b/apps/desktop/src/hermes-profile-scope.test.ts index 88c920da3de6..b61518492e6e 100644 --- a/apps/desktop/src/hermes-profile-scope.test.ts +++ b/apps/desktop/src/hermes-profile-scope.test.ts @@ -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') diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 5006ce417edc..cf0ef688a36c 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -369,12 +369,14 @@ export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: bool export function getMemoryProviderConfig(provider: string): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: `/api/memory/providers/${encodeURIComponent(provider)}/config` }) } export function saveMemoryProviderConfig(provider: string, values: Record): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ + ...profileScoped(), path: `/api/memory/providers/${encodeURIComponent(provider)}/config`, method: 'PUT', body: { values } diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8df2ff57bbeb..a2d788e3d886 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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 diff --git a/plugins/memory/config_schema.py b/plugins/memory/config_schema.py index dffcb5b10380..bb22f6df1f24 100644 --- a/plugins/memory/config_schema.py +++ b/plugins/memory/config_schema.py @@ -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 diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 862658c17e5e..b031766e1596 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -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)) diff --git a/tests/plugins/memory/test_config_schema.py b/tests/plugins/memory/test_config_schema.py index b88caced322a..db4d999710af 100644 --- a/tests/plugins/memory/test_config_schema.py +++ b/tests/plugins/memory/test_config_schema.py @@ -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"