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:
committed by
kshitij
co-authored by
BROCCOLO1D
parent
aa32edcac5
commit
827ce602db
@@ -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."""
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Tests for `hermes memory setup [provider]` routing.
|
||||
|
||||
The `memory setup` subcommand accepts an optional positional ``provider`` so a
|
||||
fresh install can configure a specific provider directly (e.g.
|
||||
``hermes memory setup honcho``) without the interactive picker — which matters
|
||||
because the per-provider ``hermes <provider>`` subcommand is only registered
|
||||
once that provider is active.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli import memory_setup
|
||||
|
||||
|
||||
class TestMemorySetupProviderRouting:
|
||||
def test_setup_with_provider_arg_skips_picker(self):
|
||||
"""`memory setup honcho` routes straight to cmd_setup_provider."""
|
||||
args = SimpleNamespace(memory_command="setup", provider="honcho")
|
||||
with patch.object(memory_setup, "cmd_setup_provider") as direct, \
|
||||
patch.object(memory_setup, "cmd_setup") as picker:
|
||||
memory_setup.memory_command(args)
|
||||
direct.assert_called_once_with("honcho")
|
||||
picker.assert_not_called()
|
||||
|
||||
def test_setup_without_provider_runs_picker(self):
|
||||
"""`memory setup` (no provider) runs the interactive picker."""
|
||||
args = SimpleNamespace(memory_command="setup", provider=None)
|
||||
with patch.object(memory_setup, "cmd_setup_provider") as direct, \
|
||||
patch.object(memory_setup, "cmd_setup") as picker:
|
||||
memory_setup.memory_command(args)
|
||||
picker.assert_called_once_with(args)
|
||||
direct.assert_not_called()
|
||||
|
||||
def test_setup_with_missing_provider_attr_runs_picker(self):
|
||||
"""A SimpleNamespace lacking `provider` must not crash — fall back to picker."""
|
||||
args = SimpleNamespace(memory_command="setup")
|
||||
with patch.object(memory_setup, "cmd_setup_provider") as direct, \
|
||||
patch.object(memory_setup, "cmd_setup") as picker:
|
||||
memory_setup.memory_command(args)
|
||||
picker.assert_called_once_with(args)
|
||||
direct.assert_not_called()
|
||||
|
||||
def test_unknown_provider_reports_and_returns_early(self, capsys):
|
||||
"""An unknown provider name surfaces a helpful message and returns
|
||||
before any config load/save (the not-found guard precedes those imports)."""
|
||||
memory_setup.cmd_setup_provider("notaprovider")
|
||||
out = capsys.readouterr().out
|
||||
assert "not found" in out
|
||||
assert "hermes memory setup" in out
|
||||
@@ -754,8 +754,8 @@ class TestRenameProfile:
|
||||
|
||||
cfg = json.loads(honcho_path.read_text())
|
||||
assert "hermes.ssi_health" not in cfg["hosts"]
|
||||
assert cfg["hosts"]["hermes.heimdall"]["aiPeer"] == "ssi_health"
|
||||
assert cfg["hosts"]["hermes.heimdall"]["peerName"] == "user-peer"
|
||||
assert cfg["hosts"]["hermes_heimdall"]["aiPeer"] == "ssi_health"
|
||||
assert cfg["hosts"]["hermes_heimdall"]["peerName"] == "user-peer"
|
||||
|
||||
def test_pins_ai_peer_when_absent_on_honcho_host_rename(self, profile_env):
|
||||
tmp_path = profile_env
|
||||
@@ -772,8 +772,8 @@ class TestRenameProfile:
|
||||
|
||||
cfg = json.loads(honcho_path.read_text())
|
||||
assert "hermes.ssi_health" not in cfg["hosts"]
|
||||
assert cfg["hosts"]["hermes.heimdall"]["aiPeer"] == "ssi_health"
|
||||
assert cfg["hosts"]["hermes.heimdall"]["workspace"] == "hermes"
|
||||
assert cfg["hosts"]["hermes_heimdall"]["aiPeer"] == "ssi_health"
|
||||
assert cfg["hosts"]["hermes_heimdall"]["workspace"] == "hermes"
|
||||
|
||||
def test_does_not_overwrite_existing_honcho_host_on_rename(self, profile_env):
|
||||
tmp_path = profile_env
|
||||
@@ -782,7 +782,7 @@ class TestRenameProfile:
|
||||
honcho_path.write_text(json.dumps({
|
||||
"hosts": {
|
||||
"hermes.ssi_health": {"aiPeer": "ssi_health"},
|
||||
"hermes.heimdall": {"aiPeer": "heimdall"},
|
||||
"hermes_heimdall": {"aiPeer": "heimdall"},
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -791,7 +791,7 @@ class TestRenameProfile:
|
||||
|
||||
cfg = json.loads(honcho_path.read_text())
|
||||
assert cfg["hosts"]["hermes.ssi_health"]["aiPeer"] == "ssi_health"
|
||||
assert cfg["hosts"]["hermes.heimdall"]["aiPeer"] == "heimdall"
|
||||
assert cfg["hosts"]["hermes_heimdall"]["aiPeer"] == "heimdall"
|
||||
|
||||
def test_default_raises_value_error(self, profile_env):
|
||||
with pytest.raises(ValueError, match="default"):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ turn counting, tags), and schema completeness.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
@@ -1570,3 +1572,13 @@ class TestShutdown:
|
||||
assert embedded._client is None
|
||||
assert provider._client is None
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits not enforced on Windows")
|
||||
def test_save_config_sets_owner_only_permissions(tmp_path):
|
||||
"""hindsight/config.json must be written with 0o600 so API key is not world-readable."""
|
||||
provider = HindsightMemoryProvider()
|
||||
provider.save_config({"api_key": "hd-test-key"}, str(tmp_path))
|
||||
config_file = tmp_path / "hindsight" / "config.json"
|
||||
assert config_file.exists()
|
||||
mode = stat.S_IMODE(config_file.stat().st_mode)
|
||||
assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}"
|
||||
|
||||
@@ -4,6 +4,10 @@ Salvaged from PRs #5301 (qaqcvc) and #5117 (vvvanguards).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.memory.mem0 import Mem0MemoryProvider
|
||||
|
||||
@@ -202,6 +206,17 @@ class TestMem0ResponseUnwrapping:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits not enforced on Windows")
|
||||
def test_save_config_sets_owner_only_permissions(tmp_path):
|
||||
"""mem0.json must be written with 0o600 so API key is not world-readable."""
|
||||
provider = Mem0MemoryProvider()
|
||||
provider.save_config({"api_key": "m0-test-key"}, str(tmp_path))
|
||||
config_file = tmp_path / "mem0.json"
|
||||
assert config_file.exists()
|
||||
mode = stat.S_IMODE(config_file.stat().st_mode)
|
||||
assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}"
|
||||
|
||||
|
||||
class TestMem0Defaults:
|
||||
"""Ensure we don't break existing users' defaults."""
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
@@ -409,3 +411,13 @@ def test_get_config_schema_minimal():
|
||||
assert len(schema) == 1
|
||||
assert schema[0]["key"] == "api_key"
|
||||
assert schema[0]["secret"] is True
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits not enforced on Windows")
|
||||
def test_save_config_sets_owner_only_permissions(tmp_path):
|
||||
"""supermemory.json must be written with 0o600 so API key is not world-readable."""
|
||||
_save_supermemory_config({"api_key": "sm-test-key"}, str(tmp_path))
|
||||
config_file = tmp_path / "supermemory.json"
|
||||
assert config_file.exists()
|
||||
mode = stat.S_IMODE(config_file.stat().st_mode)
|
||||
assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}"
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
from plugins.memory.honcho import HonchoMemoryProvider
|
||||
|
||||
|
||||
class TestHonchoClientConfigAutoEnable:
|
||||
@@ -100,3 +104,24 @@ class TestHonchoClientConfigAutoEnable:
|
||||
|
||||
assert cfg.api_key == "fallback-key"
|
||||
assert cfg.enabled is True # from_env() sets enabled=True
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits not enforced on Windows")
|
||||
def test_save_config_sets_owner_only_permissions(tmp_path, monkeypatch):
|
||||
"""honcho.json is created atomically with 0o600, not chmod-after-write."""
|
||||
import utils
|
||||
calls = []
|
||||
real_atomic = utils.atomic_json_write
|
||||
|
||||
def spy(path, data, **kwargs):
|
||||
calls.append(kwargs.get("mode"))
|
||||
return real_atomic(path, data, **kwargs)
|
||||
|
||||
monkeypatch.setattr(utils, "atomic_json_write", spy)
|
||||
provider = HonchoMemoryProvider()
|
||||
provider.save_config({"api_key": "hc-test-key"}, str(tmp_path))
|
||||
assert calls == [0o600]
|
||||
config_file = tmp_path / "honcho.json"
|
||||
assert config_file.exists()
|
||||
mode = stat.S_IMODE(config_file.stat().st_mode)
|
||||
assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}"
|
||||
|
||||
Reference in New Issue
Block a user