diff --git a/gateway/run.py b/gateway/run.py index 5cdc5894c..1b2220a56 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -15331,8 +15331,52 @@ class GatewayRunner: ("compression", "target_ratio"), ("compression", "protect_last_n"), ("agent", "disabled_toolsets"), + ("memory", "provider"), ) + _HONCHO_CACHE_BUSTING_KEYS = ( + "honcho.peer_name", + "honcho.ai_peer", + "honcho.pin_peer_name", + "honcho.runtime_peer_prefix", + "honcho.user_peer_aliases", + ) + _HONCHO_CACHE_BUSTING_MEMO: dict[tuple[str, int | None], dict[str, Any]] = {} + + @classmethod + def _empty_honcho_cache_busting_config(cls) -> dict[str, Any]: + return {key: None for key in cls._HONCHO_CACHE_BUSTING_KEYS} + + @classmethod + def _extract_honcho_cache_busting_config(cls) -> dict[str, Any]: + """Extract Honcho identity keys, memoized by honcho.json mtime.""" + try: + from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path + + path = resolve_config_path() + try: + mtime_ns = path.stat().st_mtime_ns + except OSError: + mtime_ns = None + memo_key = (str(path), mtime_ns) + cached = cls._HONCHO_CACHE_BUSTING_MEMO.get(memo_key) + if cached is not None: + return dict(cached) + + hcfg = HonchoClientConfig.from_global_config(config_path=path) + aliases = hcfg.user_peer_aliases or {} + values = { + "honcho.peer_name": hcfg.peer_name, + "honcho.ai_peer": hcfg.ai_peer, + "honcho.pin_peer_name": bool(hcfg.pin_peer_name), + "honcho.runtime_peer_prefix": hcfg.runtime_peer_prefix or "", + "honcho.user_peer_aliases": sorted(aliases.items()) if isinstance(aliases, dict) else [], + } + cls._HONCHO_CACHE_BUSTING_MEMO = {memo_key: values} + return dict(values) + except Exception: + return cls._empty_honcho_cache_busting_config() + @classmethod def _extract_cache_busting_config(cls, user_config: dict | None) -> dict: """Pull values that must bust the cached agent. @@ -15363,26 +15407,12 @@ class GatewayRunner: out["tools.registry_generation"] = None # Honcho identity-mapping keys live in honcho.json, not user_config. - # HonchoSessionManager freezes the resolved peer_name / ai_peer / - # pin / aliases / prefix at construction; without busting here, - # mid-flight honcho.json edits go unread until the next unrelated - # cache eviction. - try: - from plugins.memory.honcho.client import HonchoClientConfig - - hcfg = HonchoClientConfig.from_global_config() - out["honcho.peer_name"] = hcfg.peer_name - out["honcho.ai_peer"] = hcfg.ai_peer - out["honcho.pin_peer_name"] = bool(hcfg.pin_peer_name) - out["honcho.runtime_peer_prefix"] = hcfg.runtime_peer_prefix or "" - aliases = hcfg.user_peer_aliases or {} - out["honcho.user_peer_aliases"] = sorted(aliases.items()) if isinstance(aliases, dict) else [] - except Exception: - out["honcho.peer_name"] = None - out["honcho.ai_peer"] = None - out["honcho.pin_peer_name"] = None - out["honcho.runtime_peer_prefix"] = None - out["honcho.user_peer_aliases"] = None + # Only read that file when Honcho is the active memory provider. + provider = cfg_get(cfg, "memory", "provider") + if isinstance(provider, str) and provider.lower() == "honcho": + out.update(cls._extract_honcho_cache_busting_config()) + else: + out.update(cls._empty_honcho_cache_busting_config()) return out diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 76bd12a53..79dd50c23 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -13029,9 +13029,15 @@ Examples: ), ) memory_sub = memory_parser.add_subparsers(dest="memory_command") - memory_sub.add_parser( + _setup_parser = memory_sub.add_parser( "setup", help="Interactive provider selection and configuration" ) + _setup_parser.add_argument( + "provider", + nargs="?", + default=None, + help="Provider to configure directly (e.g. honcho), skipping the picker", + ) memory_sub.add_parser("status", help="Show current memory provider config") memory_sub.add_parser("off", help="Disable external provider (built-in only)") _reset_parser = memory_sub.add_parser( diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index cac13bf78..a75c10b02 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -452,7 +452,11 @@ def memory_command(args) -> None: """Route memory subcommands.""" sub = getattr(args, "memory_command", None) if sub == "setup": - cmd_setup(args) + provider = getattr(args, "provider", None) + if provider: + cmd_setup_provider(provider) + else: + cmd_setup(args) elif sub == "status": cmd_status(args) else: diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index f490cbbfb..31dbf8dfb 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -1471,8 +1471,9 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path: def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) -> None: """Rename Honcho host blocks for a renamed profile without changing peers.""" - old_host = f"hermes.{old_name}" - new_host = f"hermes.{new_name}" + old_host = f"hermes_{old_name}" + legacy_old_host = f"hermes.{old_name}" + new_host = f"hermes_{new_name}" candidates = [ new_dir / "honcho.json", @@ -1496,18 +1497,24 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) -> continue hosts = raw.get("hosts") - if not isinstance(hosts, dict) or old_host not in hosts: + if not isinstance(hosts, dict): + continue + source_host = old_host if old_host in hosts else legacy_old_host + if source_host not in hosts: continue if new_host in hosts: print(f"⚠ Honcho host block not migrated: {new_host} already exists in {path}") continue - block = hosts[old_host] + block = hosts[source_host] if isinstance(block, dict) and "aiPeer" not in block: - bare = old_host.split(".", 1)[1] if "." in old_host else old_host + if source_host.startswith("hermes_"): + bare = source_host.split("_", 1)[1] + else: + bare = source_host.split(".", 1)[1] if "." in source_host else source_host block["aiPeer"] = bare - hosts[new_host] = hosts.pop(old_host) + hosts[new_host] = hosts.pop(source_host) tmp = path.with_suffix(path.suffix + ".tmp") try: tmp.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") @@ -1519,7 +1526,7 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) -> pass continue - print(f"✓ Honcho host updated: {old_host} → {new_host}") + print(f"✓ Honcho host updated: {source_host} → {new_host}") def rename_profile(old_name: str, new_name: str) -> Path: diff --git a/optional-skills/autonomous-ai-agents/honcho/SKILL.md b/optional-skills/autonomous-ai-agents/honcho/SKILL.md index 865d844df..b4a24a46e 100644 --- a/optional-skills/autonomous-ai-agents/honcho/SKILL.md +++ b/optional-skills/autonomous-ai-agents/honcho/SKILL.md @@ -32,14 +32,14 @@ Honcho provides AI-native cross-session user modeling. It learns who the user is ### Cloud (app.honcho.dev) ```bash -hermes honcho setup +hermes memory setup honcho # select "cloud", paste API key from https://app.honcho.dev ``` ### Self-hosted ```bash -hermes honcho setup +hermes memory setup honcho # select "local", enter base URL (e.g. http://localhost:8000) ``` diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index ef8fcafb8..2f94c08da 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -633,7 +633,8 @@ class HindsightMemoryProvider(MemoryProvider): except Exception: pass existing.update(values) - config_path.write_text(json.dumps(existing, indent=2)) + from utils import atomic_json_write + atomic_json_write(config_path, existing, mode=0o600) def post_setup(self, hermes_home: str, config: dict) -> None: """Custom setup wizard — installs only the deps needed for the selected mode.""" diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index dbe3eebc9..3774747d0 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -12,8 +12,8 @@ AI-native cross-session user modeling with multi-pass dialectic reasoning, sessi ## Setup ```bash -hermes honcho setup # full interactive wizard (cloud or local) -hermes memory setup # generic picker, also works +hermes memory setup honcho # configure Honcho directly (works on a fresh install) +hermes memory setup # generic picker, choose Honcho from the list ``` Or manually: @@ -22,6 +22,10 @@ hermes config set memory.provider honcho echo "HONCHO_API_KEY=***" >> ~/.hermes/.env ``` +> `hermes honcho setup` also works, but only **after** Honcho is the active +> memory provider — the `honcho` subcommand is registered for the active +> provider only. On a fresh install, use `hermes memory setup honcho`. + ## Architecture Overview ### Two-Layer Context Injection @@ -109,7 +113,7 @@ Config is read from the first file that exists: | 2 | `~/.hermes/honcho.json` | Default profile (shared host blocks) | | 3 | `~/.honcho/config.json` | Global (cross-app interop) | -Host key is derived from the active Hermes profile: `hermes` (default) or `hermes.`. +Host key is derived from the active Hermes profile: `hermes` (default) or `hermes_`. For every key, resolution order is: **host block > root > env var > default**. @@ -154,7 +158,7 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a **Host vs root semantics.** All three keys are accepted at both root and `hosts.` levels. Host-level wins. For maps and prefixes, host-level *replaces* the root value as a whole (not merge), so a host can intentionally own its identity universe or wipe it with `userPeerAliases: {}` / `runtimePeerPrefix: ""`. -**Deployment shapes** (`hermes honcho setup` asks one prompt to set these): +**Deployment shapes** (`hermes memory setup honcho` asks one prompt to set these): - **Single-operator** — `pinUserPeer: true`. All gateway users → `peerName`. Recommended for personal use where you connect Hermes to your own Telegram/Discord/etc. - **Multi-user gateway** — `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. Recommended for bots serving many humans. @@ -225,7 +229,7 @@ Multiple Hermes profiles can share one workspace while maintaining separate AI i "recallMode": "hybrid", "sessionStrategy": "per-directory" }, - "hermes.coder": { + "hermes_coder": { "aiPeer": "coder", "recallMode": "tools", "sessionStrategy": "per-repo" @@ -236,7 +240,7 @@ Multiple Hermes profiles can share one workspace while maintaining separate AI i Both profiles see the same user (`yourname`) in the same shared environment (`hermes`), but each AI peer builds its own observations, conclusions, and behavior patterns. The coder's memory stays code-oriented; the main agent's stays broad. -Host key is derived from the active Hermes profile: `hermes` (default) or `hermes.` (e.g. `hermes -p coder` → host key `hermes.coder`). +Host key is derived from the active Hermes profile: `hermes` (default) or `hermes_` (e.g. `hermes -p coder` -> host key `hermes_coder`). Older `hermes.` host blocks are still read for compatibility and are migrated when the CLI writes profile-scoped Honcho config. ### Dialectic & Reasoning @@ -307,7 +311,8 @@ Presets: | Command | Description | |---------|-------------| -| `hermes honcho setup` | Full interactive setup wizard | +| `hermes memory setup honcho` | Configure Honcho directly — works on a fresh install | +| `hermes honcho setup` | Interactive setup wizard (only registered once Honcho is the active provider; redirects to `hermes memory setup`) | | `hermes honcho status` | Show resolved config for active profile | | `hermes honcho enable` / `disable` | Toggle Honcho for active profile | | `hermes honcho mode ` | Change recall or observation mode | @@ -344,7 +349,7 @@ Presets: "dialecticMaxChars": 600, "saveMessages": true }, - "hermes.coder": { + "hermes_coder": { "enabled": true, "aiPeer": "coder", "sessionStrategy": "per-repo", diff --git a/plugins/memory/honcho/__init__.py b/plugins/memory/honcho/__init__.py index bbff0d0e6..6e6f39b8c 100644 --- a/plugins/memory/honcho/__init__.py +++ b/plugins/memory/honcho/__init__.py @@ -249,6 +249,7 @@ class HonchoMemoryProvider(MemoryProvider): def save_config(self, values, hermes_home): """Write config to $HERMES_HOME/honcho.json (Honcho SDK native format).""" import json + import os from pathlib import Path config_path = Path(hermes_home) / "honcho.json" existing = {} @@ -258,7 +259,8 @@ class HonchoMemoryProvider(MemoryProvider): except Exception: pass existing.update(values) - config_path.write_text(json.dumps(existing, indent=2)) + from utils import atomic_json_write + atomic_json_write(config_path, existing, mode=0o600) def get_config_schema(self): return [ diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 9227bf95a..ce2af8a08 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -11,7 +11,7 @@ import sys from pathlib import Path from hermes_constants import get_hermes_home -from plugins.memory.honcho.client import resolve_active_host, resolve_config_path, HOST +from plugins.memory.honcho.client import _host_block, profile_host_key, resolve_active_host, resolve_config_path, HOST from hermes_cli.config import cfg_get @@ -36,7 +36,7 @@ def clone_honcho_for_profile(profile_name: str) -> bool: if not default_block and not has_key: return False - new_host = f"{HOST}.{profile_name}" + new_host = profile_host_key(profile_name) if new_host in hosts: return False # already exists @@ -192,7 +192,7 @@ def cmd_sync(args) -> None: if p.name == "default": continue if clone_honcho_for_profile(p.name): - print(f" + {p.name} -> hermes.{p.name}") + print(f" + {p.name} -> {profile_host_key(p.name)}") created += 1 else: skipped += 1 @@ -243,7 +243,7 @@ def _host_key() -> str: if _profile_override: if _profile_override in {"default", "custom"}: return HOST - return f"{HOST}.{_profile_override}" + return profile_host_key(_profile_override) return resolve_active_host() @@ -275,10 +275,8 @@ def _read_config() -> dict: def _write_config(cfg: dict, path: Path | None = None) -> None: path = path or _local_config_path() path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json.dumps(cfg, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) + from utils import atomic_json_write + atomic_json_write(path, cfg, mode=0o600) def _resolve_api_key(cfg: dict) -> str: @@ -292,7 +290,7 @@ def _resolve_api_key(cfg: dict) -> str: config shapes, e.g. ``localhost:8000``) still pass — the Honcho SDK will reject them itself with a clearer error than ours. """ - host_key = ((cfg.get("hosts") or {}).get(_host_key()) or {}).get("apiKey") + host_key = _host_block(cfg, _host_key()).get("apiKey") key = host_key or cfg.get("apiKey", "") or os.environ.get("HONCHO_API_KEY", "") if not key: base_url = cfg.get("baseUrl") or cfg.get("base_url") or os.environ.get("HONCHO_BASE_URL", "") @@ -462,21 +460,58 @@ def cmd_setup(args) -> None: cfg.pop("base_url", None) if is_local: - # --- Local: ask for base URL, skip or clear API key --- + # --- Local: ask for base URL, optionally accept a JWT for auth --- current_url = cfg.get("baseUrl") or "" new_url = _prompt("Base URL", default=current_url or "http://localhost:8000") if new_url: cfg["baseUrl"] = new_url - # For local no-auth, the SDK must not send an API key. - # We keep the key in config (for cloud switching later) but - # the client should skip auth when baseUrl is local. - current_key = cfg.get("apiKey", "") - if current_key: - print(f"\n API key present in config (kept for cloud/hybrid use).") - print(" Local connections will skip auth automatically.") + # Self-hosted Honcho can run with AUTH_USE_AUTH=true and an + # AUTH_JWT_SECRET on the server side. In that case clients must + # send a JWT signed with that secret as the bearer token (the + # Honcho SDK takes it via ``api_key=``). Cloud users got prompted + # for a key already; the local path historically skipped this and + # forced users to disable auth on the server. Offer the prompt + # here too. We store it under the host block (not the top-level + # apiKey) so ``get_honcho_client`` recognises it as an explicit + # local auth opt-in (see ``_host_has_key`` in client.py) and + # cloud/hybrid switching is unaffected. + current_host_key = hermes_host.get("apiKey", "") + masked = ( + f"...{current_host_key[-8:]}" + if len(current_host_key) > 8 + else ("set" if current_host_key else "not set") + ) + print( + "\n Local Honcho auth (JWT signed with the server's " + "AUTH_JWT_SECRET)." + ) + print( + " Leave blank if your server runs with AUTH_USE_AUTH=false. " + f"Current: {masked}" + ) + new_local_key = _prompt( + "Local JWT / bearer token (blank to skip / keep current)", + secret=True, + ) + if new_local_key: + hermes_host["apiKey"] = new_local_key + elif current_host_key: + print(" Keeping existing local JWT.") else: - print("\n No API key set. Local no-auth ready.") + # Surface the top-level key situation for transparency. + top_key = cfg.get("apiKey", "") + if top_key: + print( + "\n Top-level API key present in config (kept for " + "cloud/hybrid use)." + ) + print( + " Local connections will skip auth automatically " + "until a local JWT is set above." + ) + else: + print("\n No local JWT set. Local no-auth ready.") else: # --- Cloud: set default base URL, require API key --- cfg.pop("baseUrl", None) # cloud uses SDK default diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index 3d31bd7a1..ae837a0b1 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -32,6 +32,24 @@ logger = logging.getLogger(__name__) HOST = "hermes" +def profile_host_key(profile: str | None) -> str: + """Return the safe Honcho host key for a Hermes profile.""" + if not profile or profile in {"default", "custom"}: + return HOST + sanitized = "".join(c if c.isalnum() or c in "_-" else "_" for c in profile).strip("_") + return f"{HOST}_{sanitized or 'profile'}" + + +def _host_block(raw: dict, host: str) -> dict: + """Return host config, accepting legacy dot-form profile host keys.""" + hosts = raw.get("hosts") or {} + block = hosts.get(host, {}) + if block or not host.startswith(f"{HOST}_"): + return block + legacy = f"{HOST}.{host[len(HOST) + 1:]}" + return hosts.get(legacy, {}) + + def resolve_active_host() -> str: """Derive the Honcho host key from the active Hermes profile. @@ -47,8 +65,7 @@ def resolve_active_host() -> str: try: from hermes_cli.profiles import get_active_profile_name profile = get_active_profile_name() - if profile and profile not in {"default", "custom"}: - return f"{HOST}.{profile}" + return profile_host_key(profile) except Exception: pass return HOST @@ -406,7 +423,7 @@ class HonchoClientConfig: logger.warning("Failed to read %s: %s, falling back to env", path, e) return cls.from_env(host=resolved_host) - host_block = (raw.get("hosts") or {}).get(resolved_host, {}) + host_block = _host_block(raw, resolved_host) # A hosts.hermes block or explicit enabled flag means the user # intentionally configured Honcho for this host. _explicitly_configured = bool(host_block) or raw.get("enabled") is True @@ -811,7 +828,10 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho: or "::1" in resolved_base_url ) if _is_local: - # Check if the host block has its own apiKey (explicit local auth) + # Check if the host block has its own apiKey (explicit local auth). + # Auth-skipping is loopback-only: a stored key is likely a cloud key + # that would break a no-auth local server, so we substitute the SDK's + # required-non-empty placeholder unless the host block opts in. _raw = config.raw or {} _host_block = (_raw.get("hosts") or {}).get(config.host, {}) _host_has_key = bool(_host_block.get("apiKey")) @@ -819,6 +839,18 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho: else: effective_api_key = config.api_key + # The Honcho SDK's route builders (e.g. routes.workspaces()) already + # include the version prefix (e.g. "/v3/workspaces"). When a user-supplied + # base_url already ends in a version segment (e.g. + # "http://localhost:38000/v3", "https://honcho.my.ts.net/v3"), concatenating + # the two produces "/v3/v3/workspaces" → 404 on every call. This is a pure + # routing concern independent of host, so strip a trailing version segment + # from ANY base_url — loopback, LAN, custom domain, or cloud alike. The + # SDK then appends its own versioned paths correctly. + if resolved_base_url: + import re as _re + resolved_base_url = _re.sub(r"/v\d+/*$", "", resolved_base_url).rstrip("/") + kwargs: dict = { "workspace_id": config.workspace_id, "api_key": effective_api_key, diff --git a/plugins/memory/mem0/__init__.py b/plugins/memory/mem0/__init__.py index 32d1f6ff7..332b3ac94 100644 --- a/plugins/memory/mem0/__init__.py +++ b/plugins/memory/mem0/__init__.py @@ -155,7 +155,8 @@ class Mem0MemoryProvider(MemoryProvider): except Exception: pass existing.update(values) - config_path.write_text(json.dumps(existing, indent=2)) + from utils import atomic_json_write + atomic_json_write(config_path, existing, mode=0o600) def get_config_schema(self): return [ diff --git a/plugins/memory/supermemory/__init__.py b/plugins/memory/supermemory/__init__.py index 35b5b6fd6..a21ae53cc 100644 --- a/plugins/memory/supermemory/__init__.py +++ b/plugins/memory/supermemory/__init__.py @@ -152,7 +152,8 @@ def _save_supermemory_config(values: dict, hermes_home: str) -> None: except Exception: existing = {} existing.update(values) - config_path.write_text(json.dumps(existing, indent=2, sort_keys=True) + "\n", encoding="utf-8") + from utils import atomic_json_write + atomic_json_write(config_path, existing, mode=0o600, sort_keys=True) def _detect_category(text: str) -> str: diff --git a/tests/gateway/test_agent_cache.py b/tests/gateway/test_agent_cache.py index 0c6e2df3b..37f8b51a4 100644 --- a/tests/gateway/test_agent_cache.py +++ b/tests/gateway/test_agent_cache.py @@ -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.""" diff --git a/tests/hermes_cli/test_memory_setup_provider_arg.py b/tests/hermes_cli/test_memory_setup_provider_arg.py new file mode 100644 index 000000000..6dd310094 --- /dev/null +++ b/tests/hermes_cli/test_memory_setup_provider_arg.py @@ -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 `` 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 diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index 22e36d421..dd3360309 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -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"): diff --git a/tests/honcho_plugin/test_cli.py b/tests/honcho_plugin/test_cli.py index 8244badc2..74b7e1bc3 100644 --- a/tests/honcho_plugin/test_cli.py +++ b/tests/honcho_plugin/test_cli.py @@ -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..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 diff --git a/tests/honcho_plugin/test_client.py b/tests/honcho_plugin/test_client.py index a02e6937a..929df4283 100644 --- a/tests/honcho_plugin/test_client.py +++ b/tests/honcho_plugin/test_client.py @@ -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}" + ) diff --git a/tests/honcho_plugin/test_pin_peer_name.py b/tests/honcho_plugin/test_pin_peer_name.py index ef3a215f3..1e72bc97d 100644 --- a/tests/honcho_plugin/test_pin_peer_name.py +++ b/tests/honcho_plugin/test_pin_peer_name.py @@ -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"] diff --git a/tests/plugins/memory/test_hindsight_provider.py b/tests/plugins/memory/test_hindsight_provider.py index bc62b7f2c..f49c22761 100644 --- a/tests/plugins/memory/test_hindsight_provider.py +++ b/tests/plugins/memory/test_hindsight_provider.py @@ -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)}" diff --git a/tests/plugins/memory/test_mem0_v2.py b/tests/plugins/memory/test_mem0_v2.py index 1ef85499b..a9a866764 100644 --- a/tests/plugins/memory/test_mem0_v2.py +++ b/tests/plugins/memory/test_mem0_v2.py @@ -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.""" diff --git a/tests/plugins/memory/test_supermemory_provider.py b/tests/plugins/memory/test_supermemory_provider.py index 0aee45975..d5f1c5bb1 100644 --- a/tests/plugins/memory/test_supermemory_provider.py +++ b/tests/plugins/memory/test_supermemory_provider.py @@ -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)}" diff --git a/tests/test_honcho_client_config.py b/tests/test_honcho_client_config.py index d4c62d610..f7b1efa15 100644 --- a/tests/test_honcho_client_config.py +++ b/tests/test_honcho_client_config.py @@ -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)}" diff --git a/utils.py b/utils.py index 156fd38bd..cb08ba128 100644 --- a/utils.py +++ b/utils.py @@ -87,6 +87,7 @@ def atomic_json_write( data: Any, *, indent: int = 2, + mode: int | None = None, **dump_kwargs: Any, ) -> None: """Write JSON data to a file atomically. @@ -99,13 +100,16 @@ def atomic_json_write( path: Target file path (will be created or overwritten). data: JSON-serializable data to write. indent: JSON indentation (default 2). + mode: Optional final permission mode. When set, the temp file is + created and replaced with this mode, avoiding chmod-after-write + TOCTOU exposure for secret-bearing files. **dump_kwargs: Additional keyword args forwarded to json.dump(), such as default=str for non-native types. """ path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) - original_mode = _preserve_file_mode(path) + original_mode = None if mode is not None else _preserve_file_mode(path) fd, tmp_path = tempfile.mkstemp( dir=str(path.parent), @@ -113,6 +117,8 @@ def atomic_json_write( suffix=".tmp", ) try: + if mode is not None: + os.fchmod(fd, mode) with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump( data, @@ -125,7 +131,13 @@ def atomic_json_write( os.fsync(f.fileno()) # Preserve symlinks — swap in-place on the real file (GitHub #16743). real_path = atomic_replace(tmp_path, path) - _restore_file_mode(real_path, original_mode) + if mode is not None: + try: + os.chmod(real_path, mode) + except OSError: + pass + else: + _restore_file_mode(Path(real_path), original_mode) except BaseException: # Intentionally catch BaseException so temp-file cleanup still runs for # KeyboardInterrupt/SystemExit before re-raising the original signal. diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index 61dd73e8f..b971bea27 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -106,6 +106,10 @@ The auto-injected dialectic scales `dialecticReasoningLevel` by query length: +1 Honcho is configured in `~/.honcho/config.json` (global) or `$HERMES_HOME/honcho.json` (profile-local). The setup wizard handles this for you. +### Self-Hosted Honcho with Authentication + +When pointing Hermes at a self-hosted Honcho server, `hermes honcho setup` (and `hermes memory setup`) ask for a **local JWT / bearer token** after the base URL. Paste a JWT signed with the server's `AUTH_JWT_SECRET` (the Honcho compose env var) to enable authenticated access; leave it blank for servers running with `AUTH_USE_AUTH=false`. The local token is stored under the host block (`hosts..apiKey` in `honcho.json`), separate from any cloud `apiKey`, so you can flip the `Cloud or local?` prompt back to `cloud` later without losing either credential. + ### Full Config Reference | Key | Default | Description | @@ -199,11 +203,12 @@ When Honcho is active as the memory provider, five tools become available: ## CLI Commands -The `hermes honcho` subcommand is **only registered when Honcho is the active memory provider** (`memory.provider: honcho` in `config.yaml`). Run `hermes memory setup` and pick Honcho first; the subcommand appears on the next invocation. +The `hermes honcho` subcommand is **only registered when Honcho is the active memory provider** (`memory.provider: honcho` in `config.yaml`). On a fresh install, configure Honcho directly with `hermes memory setup honcho` (or run `hermes memory setup` and pick it from the list); the `hermes honcho` subcommand then appears on the next invocation. ```bash +hermes memory setup honcho # Configure Honcho directly (works before activation) hermes honcho status # Connection status, config, and key settings -hermes honcho setup # Redirects to `hermes memory setup` +hermes honcho setup # Redirects to `hermes memory setup` (post-activation alias) hermes honcho strategy # Show or set session strategy (per-session/per-directory/per-repo/global) hermes honcho peer # Show or update peer names + dialectic reasoning level hermes honcho mode # Show or set recall mode (hybrid/context/tools) diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index f584c7288..00f2555d6 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -66,7 +66,7 @@ AI-native cross-session user modeling with dialectic reasoning, session-scoped c hermes memory setup # select "honcho" — runs the Honcho-specific post-setup ``` -The legacy `hermes honcho setup` command still works (it now redirects to `hermes memory setup`), but is only registered after Honcho is selected as the active memory provider. +On a fresh install, configure Honcho directly with `hermes memory setup honcho`. The legacy `hermes honcho setup` command still works (it now redirects to `hermes memory setup`), but is only registered after Honcho is selected as the active memory provider. **Config:** `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.json` (global). Resolution order: `$HERMES_HOME/honcho.json` > `~/.hermes/honcho.json` > `~/.honcho/config.json`. See the [config reference](https://github.com/NousResearch/hermes-agent/blob/main/plugins/memory/honcho/README.md) and the [Honcho integration guide](https://docs.honcho.dev/v3/guides/integrations/hermes).