fix(honcho): harden self-hosted setup paths

Self-hosted Honcho setup had four sharp edges:

- local/cloud URLs ending in /vN double-prefixed by the SDK (/v3/v3/... 404)
- authenticated local servers had no setup prompt for a JWT/bearer token
- profile-derived host keys could be dot-containing workspace IDs Honcho rejects
- memory-provider config files with API keys written world-readable per umask

This keeps existing behavior but makes those paths safer:

- strip a trailing /vN version segment from any configured baseUrl before SDK
  init (the SDK's route builders always prepend their own version prefix);
  auth-skipping stays loopback-only
- add an optional local JWT/bearer prompt in honcho setup, stored under
  hosts.<host>.apiKey
- derive new profile host keys with underscores, still reading legacy
  hermes.<profile> blocks
- write memory-provider config files atomically with 0600 via a shared
  utils.atomic_json_write(mode=) arg (honcho/hindsight/mem0/supermemory)
- skip honcho.json parsing in gateway cache-busting unless Honcho is the active
  memory provider; memoize by honcho.json mtime when active
- bust the gateway agent cache on memory.provider change
- add a hermes memory setup <provider> one-liner so fresh installs can configure
  a named provider without the picker (the per-provider hermes <provider>
  subcommand only registers once that provider is active)

Closes #20688, #29885, #26459, #30246, #33382, #32244.

Co-authored-by: BROCCOLO1D
This commit is contained in:
Erosika
2026-05-29 22:29:48 -07:00
committed by kshitij
co-authored by BROCCOLO1D
parent aa32edcac5
commit 827ce602db
25 changed files with 734 additions and 101 deletions
+105
View File
@@ -276,6 +276,111 @@ class TestExtractCacheBustingConfig:
assert out["tools.registry_generation"] == 12345
def test_skips_honcho_config_read_when_provider_is_not_honcho(self, monkeypatch):
"""Non-Honcho gateways must not read/parse honcho.json on every message."""
from gateway.run import GatewayRunner
called = False
def _boom():
nonlocal called
called = True
raise AssertionError("should not read Honcho config")
monkeypatch.setattr(GatewayRunner, "_extract_honcho_cache_busting_config", _boom)
out = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "mem0"}})
assert called is False
assert out["honcho.peer_name"] is None
assert out["honcho.user_peer_aliases"] is None
def test_reads_honcho_config_only_when_provider_is_honcho(self, monkeypatch):
from gateway.run import GatewayRunner
calls = []
def _fake():
calls.append(True)
return {
"honcho.peer_name": "eri",
"honcho.ai_peer": "hermes",
"honcho.pin_peer_name": True,
"honcho.runtime_peer_prefix": "tg_",
"honcho.user_peer_aliases": [("123", "eri")],
}
monkeypatch.setattr(GatewayRunner, "_extract_honcho_cache_busting_config", _fake)
out = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
assert calls == [True]
assert out["honcho.peer_name"] == "eri"
assert out["honcho.user_peer_aliases"] == [("123", "eri")]
def test_memory_provider_change_busts_signature(self, monkeypatch):
"""Switching memory.provider must itself change the cache-busting
signature, so the agent is rebuilt when a user swaps providers
mid-gateway (independent of the honcho.json identity keys)."""
from gateway.run import GatewayRunner
# Neutralize honcho.json reads so the only varying input is the
# provider value itself.
monkeypatch.setattr(
GatewayRunner,
"_extract_honcho_cache_busting_config",
classmethod(lambda cls: cls._empty_honcho_cache_busting_config()),
)
sig_honcho = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
sig_mem0 = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "mem0"}})
assert sig_honcho["memory.provider"] == "honcho"
assert sig_mem0["memory.provider"] == "mem0"
assert sig_honcho != sig_mem0
def test_honcho_cache_busting_config_memoized_by_mtime(self, monkeypatch, tmp_path):
"""Repeated Honcho extraction for unchanged honcho.json should reuse parse result."""
from types import SimpleNamespace
from gateway.run import GatewayRunner
config_path = tmp_path / "honcho.json"
config_path.write_text("{}")
parse_calls = []
class FakeConfig:
peer_name = "eri"
ai_peer = "hermes"
pin_peer_name = False
runtime_peer_prefix = "tg_"
user_peer_aliases = {"123": "eri"}
@classmethod
def from_global_config(cls, config_path=None):
parse_calls.append(config_path)
return cls()
fake_client = SimpleNamespace(
HonchoClientConfig=FakeConfig,
resolve_config_path=lambda: config_path,
)
monkeypatch.setitem(__import__("sys").modules, "plugins.memory.honcho.client", fake_client)
monkeypatch.setattr(GatewayRunner, "_HONCHO_CACHE_BUSTING_MEMO", {})
first = GatewayRunner._extract_honcho_cache_busting_config()
second = GatewayRunner._extract_honcho_cache_busting_config()
assert first == second
assert first["honcho.user_peer_aliases"] == [("123", "eri")]
assert parse_calls == [config_path]
config_path.write_text("{\n \"changed\": true\n}")
third = GatewayRunner._extract_honcho_cache_busting_config()
assert third == first
assert parse_calls == [config_path, config_path]
def test_full_round_trip_busts_cache_on_real_edit(self):
"""End-to-end: simulate a config edit on main and verify the
extracted cache_keys change produces a new signature."""