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
+84 -5
View File
@@ -1,6 +1,7 @@
"""Tests for plugins/memory/honcho/cli.py."""
from types import SimpleNamespace
import json
class TestResolveApiKey:
@@ -100,6 +101,84 @@ class TestResolveApiKey:
f"expected local sentinel for legacy schemeless {legacy!r}"
class TestCmdSetupLocalJwt:
"""Local-deployment setup must allow configuring a JWT for AUTH_JWT_SECRET-backed Honcho servers."""
def _run_setup(self, monkeypatch, tmp_path, initial_cfg, prompt_answers):
import plugins.memory.honcho.cli as honcho_cli
# Avoid touching real config / SDK / filesystem.
cfg_path = tmp_path / "honcho.json"
monkeypatch.setattr(honcho_cli, "_read_config", lambda: dict(initial_cfg))
monkeypatch.setattr(honcho_cli, "_local_config_path", lambda: cfg_path)
monkeypatch.setattr(honcho_cli, "_config_path", lambda: cfg_path)
monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
monkeypatch.setattr(honcho_cli, "_ensure_sdk_installed", lambda: True)
written = {}
def _capture_write(cfg, path=None):
written["cfg"] = cfg
written["path"] = path
monkeypatch.setattr(honcho_cli, "_write_config", _capture_write)
# Feed scripted prompt answers in order.
answers = list(prompt_answers)
def _fake_prompt(label, default=None, secret=False):
if not answers:
# Default-through any remaining prompts to keep the wizard moving.
return default or ""
return answers.pop(0)
monkeypatch.setattr(honcho_cli, "_prompt", _fake_prompt)
honcho_cli.cmd_setup(SimpleNamespace())
return written.get("cfg")
def test_local_setup_stores_jwt_under_host_block(self, monkeypatch, tmp_path):
"""Self-hosted users supplying a JWT must have it written under hosts.<host>.apiKey,
not as the top-level cloud apiKey, so cloud/hybrid switching is preserved and
get_honcho_client treats it as an explicit local auth opt-in."""
cfg = self._run_setup(
monkeypatch,
tmp_path,
initial_cfg={},
prompt_answers=[
"local", # deployment
"http://localhost:8000", # base URL
"my-local-jwt-token", # local JWT
],
)
assert cfg is not None
assert cfg.get("baseUrl") == "http://localhost:8000"
# Top-level apiKey must remain unset (cloud field).
assert not cfg.get("apiKey")
# The new local JWT belongs under the host block.
host_block = (cfg.get("hosts") or {}).get("hermes") or {}
assert host_block.get("apiKey") == "my-local-jwt-token"
def test_local_setup_blank_jwt_keeps_local_no_auth(self, monkeypatch, tmp_path):
"""Blank JWT prompt response on a fresh local config must not introduce an apiKey
anywhere (local no-auth Honcho deployments must still work out of the box)."""
cfg = self._run_setup(
monkeypatch,
tmp_path,
initial_cfg={},
prompt_answers=[
"local",
"http://localhost:8000",
"", # blank JWT
],
)
assert cfg is not None
assert cfg.get("baseUrl") == "http://localhost:8000"
assert not cfg.get("apiKey")
host_block = (cfg.get("hosts") or {}).get("hermes") or {}
assert not host_block.get("apiKey")
class TestCmdStatus:
def test_reports_connection_failure_when_session_setup_fails(self, monkeypatch, capsys, tmp_path):
import plugins.memory.honcho.cli as honcho_cli
@@ -192,7 +271,7 @@ class TestCloneHonchoForProfile:
honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)
ok = honcho_cli.clone_honcho_for_profile("coder")
assert ok is True
new_block = written["cfg"]["hosts"]["hermes.coder"]
new_block = written["cfg"]["hosts"]["hermes_coder"]
assert new_block["userPeerAliases"] == {"86701400": "eri", "discord-491827364": "eri"}
def test_runtime_peer_prefix_carries_into_cloned_profile(self, monkeypatch, tmp_path):
@@ -208,7 +287,7 @@ class TestCloneHonchoForProfile:
honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)
ok = honcho_cli.clone_honcho_for_profile("coder")
assert ok is True
new_block = written["cfg"]["hosts"]["hermes.coder"]
new_block = written["cfg"]["hosts"]["hermes_coder"]
assert new_block["runtimePeerPrefix"] == "telegram_"
def test_pin_peer_name_carries_into_cloned_profile(self, monkeypatch, tmp_path):
@@ -224,7 +303,7 @@ class TestCloneHonchoForProfile:
honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)
ok = honcho_cli.clone_honcho_for_profile("coder")
assert ok is True
new_block = written["cfg"]["hosts"]["hermes.coder"]
new_block = written["cfg"]["hosts"]["hermes_coder"]
assert new_block["pinPeerName"] is True
def test_unset_identity_keys_do_not_appear_in_cloned_profile(self, monkeypatch, tmp_path):
@@ -235,7 +314,7 @@ class TestCloneHonchoForProfile:
honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)
ok = honcho_cli.clone_honcho_for_profile("coder")
assert ok is True
new_block = written["cfg"]["hosts"]["hermes.coder"]
new_block = written["cfg"]["hosts"]["hermes_coder"]
assert "userPeerAliases" not in new_block
assert "runtimePeerPrefix" not in new_block
assert "pinPeerName" not in new_block
@@ -572,5 +651,5 @@ class TestCloneCarriesPinUserPeer:
ok = honcho_cli.clone_honcho_for_profile("partner")
assert ok is True
new_block = written["cfg"]["hosts"]["hermes.partner"]
new_block = written["cfg"]["hosts"]["hermes_partner"]
assert new_block["pinUserPeer"] is True
+206 -12
View File
@@ -13,6 +13,7 @@ import pytest
from plugins.memory.honcho.client import (
HonchoClientConfig,
get_honcho_client,
profile_host_key,
reset_honcho_client,
resolve_active_host,
resolve_config_path,
@@ -430,6 +431,10 @@ class TestResolveConfigPath:
class TestResolveActiveHost:
def test_profile_host_key_uses_honcho_safe_separator(self):
assert profile_host_key("coder") == "hermes_coder"
assert profile_host_key("default") == "hermes"
def test_default_returns_hermes(self):
with patch.dict(os.environ, {}, clear=True):
os.environ.pop("HERMES_HONCHO_HOST", None)
@@ -444,7 +449,7 @@ class TestResolveActiveHost:
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("HERMES_HONCHO_HOST", None)
with patch("hermes_cli.profiles.get_active_profile_name", return_value="coder"):
assert resolve_active_host() == "hermes.coder"
assert resolve_active_host() == "hermes_coder"
def test_default_profile_returns_hermes(self):
with patch.dict(os.environ, {}, clear=False):
@@ -477,10 +482,10 @@ class TestResolveActiveHost:
class TestProfileScopedConfig:
def test_from_env_uses_profile_host(self):
with patch.dict(os.environ, {"HONCHO_API_KEY": "key"}):
config = HonchoClientConfig.from_env(host="hermes.coder")
assert config.host == "hermes.coder"
config = HonchoClientConfig.from_env(host="hermes_coder")
assert config.host == "hermes_coder"
assert config.workspace_id == "hermes" # shared workspace
assert config.ai_peer == "hermes.coder"
assert config.ai_peer == "hermes_coder"
def test_from_env_default_workspace_preserved_for_default_host(self):
with patch.dict(os.environ, {"HONCHO_API_KEY": "key"}):
@@ -494,22 +499,35 @@ class TestProfileScopedConfig:
"apiKey": "shared-key",
"hosts": {
"hermes": {"aiPeer": "hermes", "peerName": "alice"},
"hermes.coder": {
"aiPeer": "hermes.coder",
"hermes_coder": {
"aiPeer": "hermes_coder",
"peerName": "alice-coder",
"workspace": "coder-ws",
},
},
}))
config = HonchoClientConfig.from_global_config(
host="hermes.coder", config_path=config_file,
host="hermes_coder", config_path=config_file,
)
assert config.host == "hermes.coder"
assert config.host == "hermes_coder"
assert config.workspace_id == "coder-ws"
assert config.ai_peer == "hermes.coder"
assert config.ai_peer == "hermes_coder"
assert config.peer_name == "alice-coder"
def test_from_global_config_auto_resolves_host(self, tmp_path):
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({
"apiKey": "key",
"hosts": {
"hermes_dreamer": {"peerName": "dreamer-user"},
},
}))
with patch("plugins.memory.honcho.client.resolve_active_host", return_value="hermes_dreamer"):
config = HonchoClientConfig.from_global_config(config_path=config_file)
assert config.host == "hermes_dreamer"
assert config.peer_name == "dreamer-user"
def test_from_global_config_reads_legacy_dot_profile_host_block(self, tmp_path):
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({
"apiKey": "key",
@@ -517,10 +535,13 @@ class TestProfileScopedConfig:
"hermes.dreamer": {"peerName": "dreamer-user"},
},
}))
with patch("plugins.memory.honcho.client.resolve_active_host", return_value="hermes.dreamer"):
config = HonchoClientConfig.from_global_config(config_path=config_file)
assert config.host == "hermes.dreamer"
config = HonchoClientConfig.from_global_config(
host="hermes_dreamer",
config_path=config_file,
)
assert config.host == "hermes_dreamer"
assert config.peer_name == "dreamer-user"
assert config.workspace_id == "hermes_dreamer"
class TestObservationModeMigration:
@@ -890,3 +911,176 @@ class TestDialecticDepthParsing:
}))
config = HonchoClientConfig.from_global_config(config_path=config_file)
assert config.dialectic_depth_levels == ["low", "high"]
class TestGetHonchoClientBaseUrlDoublePrefixFix:
"""Regression tests for #20688 — Honcho SDK double-prefixing of /v3 for
self-hosted instances where base_url already contains a version path."""
def teardown_method(self):
reset_honcho_client()
@pytest.mark.skipif(
not importlib.util.find_spec("honcho"),
reason="honcho SDK not installed"
)
def test_local_base_url_with_v3_suffix_stripped(self):
"""base_url 'http://localhost:38000/v3' must become 'http://localhost:38000'
before passing to the Honcho SDK to avoid double '/v3/v3' prefixing."""
fake_honcho = MagicMock(name="Honcho")
cfg = HonchoClientConfig(
api_key=None,
base_url="http://localhost:38000/v3",
workspace_id="hermes",
environment="production",
)
with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \
patch("hermes_cli.config.load_config", return_value={}):
get_honcho_client(cfg)
mock_honcho.assert_called_once()
passed_base_url = mock_honcho.call_args.kwargs.get("base_url")
assert passed_base_url == "http://localhost:38000", (
f"Expected 'http://localhost:38000', got {passed_base_url!r}"
)
@pytest.mark.skipif(
not importlib.util.find_spec("honcho"),
reason="honcho SDK not installed"
)
def test_local_base_url_without_version_unchanged(self):
"""base_url 'http://localhost:38000' (no version) must be passed unchanged."""
fake_honcho = MagicMock(name="Honcho")
cfg = HonchoClientConfig(
api_key=None,
base_url="http://localhost:38000",
workspace_id="hermes",
environment="production",
)
with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \
patch("hermes_cli.config.load_config", return_value={}):
get_honcho_client(cfg)
mock_honcho.assert_called_once()
passed_base_url = mock_honcho.call_args.kwargs.get("base_url")
assert passed_base_url == "http://localhost:38000", (
f"Expected 'http://localhost:38000', got {passed_base_url!r}"
)
@pytest.mark.skipif(
not importlib.util.find_spec("honcho"),
reason="honcho SDK not installed"
)
def test_cloud_base_url_without_version_unchanged(self):
"""A cloud base_url with no version segment must pass through untouched."""
fake_honcho = MagicMock(name="Honcho")
cfg = HonchoClientConfig(
api_key="cloud-key",
base_url="https://api.honcho.dev",
workspace_id="hermes",
environment="production",
)
with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \
patch("hermes_cli.config.load_config", return_value={}):
get_honcho_client(cfg)
mock_honcho.assert_called_once()
passed_base_url = mock_honcho.call_args.kwargs.get("base_url")
assert passed_base_url == "https://api.honcho.dev", (
f"Expected 'https://api.honcho.dev', got {passed_base_url!r}"
)
@pytest.mark.skipif(
not importlib.util.find_spec("honcho"),
reason="honcho SDK not installed"
)
def test_cloud_base_url_with_version_stripped(self):
"""A version segment double-prefixes regardless of host, so a cloud
base_url that ends in '/v3' must also be stripped (the SDK re-adds it)."""
fake_honcho = MagicMock(name="Honcho")
cfg = HonchoClientConfig(
api_key="cloud-key",
base_url="https://api.honcho.dev/v3",
workspace_id="hermes",
environment="production",
)
with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \
patch("hermes_cli.config.load_config", return_value={}):
get_honcho_client(cfg)
mock_honcho.assert_called_once()
passed_base_url = mock_honcho.call_args.kwargs.get("base_url")
assert passed_base_url == "https://api.honcho.dev", (
f"Expected 'https://api.honcho.dev', got {passed_base_url!r}"
)
@pytest.mark.skipif(
not importlib.util.find_spec("honcho"),
reason="honcho SDK not installed"
)
@pytest.mark.parametrize(
"raw_url, expected",
[
# LAN IP self-host
("http://10.0.0.5:8000/v3", "http://10.0.0.5:8000"),
("http://192.168.1.20:38000/v3/", "http://192.168.1.20:38000"),
# Tailscale / custom-domain self-host
("https://honcho.my.ts.net/v3", "https://honcho.my.ts.net"),
("https://honcho.lab.internal/v3", "https://honcho.lab.internal"),
("https://honcho.fly.dev/v3", "https://honcho.fly.dev"),
# higher version segments are also stripped
("https://honcho.lab.internal/v12", "https://honcho.lab.internal"),
# self-host without a version segment is left unchanged
("https://honcho.my.ts.net", "https://honcho.my.ts.net"),
("http://10.0.0.5:8000", "http://10.0.0.5:8000"),
],
)
def test_self_hosted_base_url_version_stripped(self, raw_url, expected):
"""Non-loopback self-hosted instances (LAN IPs, Tailscale, custom
domains) must get the same version-segment stripping as localhost.
Regression for #20688 recurring on any non-loopback self-host."""
fake_honcho = MagicMock(name="Honcho")
cfg = HonchoClientConfig(
api_key="self-host-key",
base_url=raw_url,
workspace_id="hermes",
environment="production",
)
with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \
patch("hermes_cli.config.load_config", return_value={}):
get_honcho_client(cfg)
mock_honcho.assert_called_once()
passed_base_url = mock_honcho.call_args.kwargs.get("base_url")
assert passed_base_url == expected, (
f"Expected {expected!r}, got {passed_base_url!r}"
)
@pytest.mark.skipif(
not importlib.util.find_spec("honcho"),
reason="honcho SDK not installed"
)
def test_local_base_url_with_trailing_slash_stripped(self):
"""base_url 'http://127.0.0.1:38000/v3/' must also be cleaned up."""
fake_honcho = MagicMock(name="Honcho")
cfg = HonchoClientConfig(
api_key=None,
base_url="http://127.0.0.1:38000/v3/",
workspace_id="hermes",
environment="production",
)
with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \
patch("hermes_cli.config.load_config", return_value={}):
get_honcho_client(cfg)
mock_honcho.assert_called_once()
passed_base_url = mock_honcho.call_args.kwargs.get("base_url")
assert passed_base_url == "http://127.0.0.1:38000", (
f"Expected 'http://127.0.0.1:38000', got {passed_base_url!r}"
)
+8 -8
View File
@@ -745,10 +745,10 @@ class TestPinTransition:
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor", "pinPeerName": True}))
sig_pinned = GatewayRunner._extract_cache_busting_config({})
sig_pinned = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor", "pinPeerName": False}))
sig_unpinned = GatewayRunner._extract_cache_busting_config({})
sig_unpinned = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
assert sig_pinned["honcho.pin_peer_name"] != sig_unpinned["honcho.pin_peer_name"]
@@ -759,14 +759,14 @@ class TestPinTransition:
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor"}))
sig_no_aliases = GatewayRunner._extract_cache_busting_config({})
sig_no_aliases = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
cfg_path.write_text(json.dumps({
"apiKey": "k",
"peerName": "Igor",
"userPeerAliases": {"86701400": "Igor"},
}))
sig_with_aliases = GatewayRunner._extract_cache_busting_config({})
sig_with_aliases = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
assert sig_no_aliases["honcho.user_peer_aliases"] != sig_with_aliases["honcho.user_peer_aliases"]
@@ -777,14 +777,14 @@ class TestPinTransition:
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor"}))
sig_no_prefix = GatewayRunner._extract_cache_busting_config({})
sig_no_prefix = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
cfg_path.write_text(json.dumps({
"apiKey": "k",
"peerName": "Igor",
"runtimePeerPrefix": "telegram_",
}))
sig_with_prefix = GatewayRunner._extract_cache_busting_config({})
sig_with_prefix = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
assert sig_no_prefix["honcho.runtime_peer_prefix"] != sig_with_prefix["honcho.runtime_peer_prefix"]
@@ -805,14 +805,14 @@ class TestPinTransition:
"peerName": "Igor",
"aiPeer": "hermes",
}))
sig_before = GatewayRunner._extract_cache_busting_config({})
sig_before = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
cfg_path.write_text(json.dumps({
"apiKey": "k",
"peerName": "Igor",
"aiPeer": "hermetika",
}))
sig_after = GatewayRunner._extract_cache_busting_config({})
sig_after = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
assert sig_before["honcho.ai_peer"] != sig_after["honcho.ai_peer"]