fix(honcho): harden self-hosted setup paths

Self-hosted Honcho setup had four sharp edges:

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

This keeps existing behavior but makes those paths safer:

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

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

Co-authored-by: BROCCOLO1D
This commit is contained in:
Erosika
2026-05-30 10:54:53 +05:30
committed by kshitij
parent aa32edcac5
commit 827ce602db
25 changed files with 734 additions and 101 deletions

View File

@ -15331,8 +15331,52 @@ class GatewayRunner:
("compression", "target_ratio"), ("compression", "target_ratio"),
("compression", "protect_last_n"), ("compression", "protect_last_n"),
("agent", "disabled_toolsets"), ("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 @classmethod
def _extract_cache_busting_config(cls, user_config: dict | None) -> dict: def _extract_cache_busting_config(cls, user_config: dict | None) -> dict:
"""Pull values that must bust the cached agent. """Pull values that must bust the cached agent.
@ -15363,26 +15407,12 @@ class GatewayRunner:
out["tools.registry_generation"] = None out["tools.registry_generation"] = None
# Honcho identity-mapping keys live in honcho.json, not user_config. # Honcho identity-mapping keys live in honcho.json, not user_config.
# HonchoSessionManager freezes the resolved peer_name / ai_peer / # Only read that file when Honcho is the active memory provider.
# pin / aliases / prefix at construction; without busting here, provider = cfg_get(cfg, "memory", "provider")
# mid-flight honcho.json edits go unread until the next unrelated if isinstance(provider, str) and provider.lower() == "honcho":
# cache eviction. out.update(cls._extract_honcho_cache_busting_config())
try: else:
from plugins.memory.honcho.client import HonchoClientConfig out.update(cls._empty_honcho_cache_busting_config())
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
return out return out

View File

@ -13029,9 +13029,15 @@ Examples:
), ),
) )
memory_sub = memory_parser.add_subparsers(dest="memory_command") 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", 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("status", help="Show current memory provider config")
memory_sub.add_parser("off", help="Disable external provider (built-in only)") memory_sub.add_parser("off", help="Disable external provider (built-in only)")
_reset_parser = memory_sub.add_parser( _reset_parser = memory_sub.add_parser(

View File

@ -452,7 +452,11 @@ def memory_command(args) -> None:
"""Route memory subcommands.""" """Route memory subcommands."""
sub = getattr(args, "memory_command", None) sub = getattr(args, "memory_command", None)
if sub == "setup": if sub == "setup":
cmd_setup(args) provider = getattr(args, "provider", None)
if provider:
cmd_setup_provider(provider)
else:
cmd_setup(args)
elif sub == "status": elif sub == "status":
cmd_status(args) cmd_status(args)
else: else:

View File

@ -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: 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.""" """Rename Honcho host blocks for a renamed profile without changing peers."""
old_host = f"hermes.{old_name}" old_host = f"hermes_{old_name}"
new_host = f"hermes.{new_name}" legacy_old_host = f"hermes.{old_name}"
new_host = f"hermes_{new_name}"
candidates = [ candidates = [
new_dir / "honcho.json", new_dir / "honcho.json",
@ -1496,18 +1497,24 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) ->
continue continue
hosts = raw.get("hosts") 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 continue
if new_host in hosts: if new_host in hosts:
print(f"⚠ Honcho host block not migrated: {new_host} already exists in {path}") print(f"⚠ Honcho host block not migrated: {new_host} already exists in {path}")
continue continue
block = hosts[old_host] block = hosts[source_host]
if isinstance(block, dict) and "aiPeer" not in block: 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 block["aiPeer"] = bare
hosts[new_host] = hosts.pop(old_host) hosts[new_host] = hosts.pop(source_host)
tmp = path.with_suffix(path.suffix + ".tmp") tmp = path.with_suffix(path.suffix + ".tmp")
try: try:
tmp.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") 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 pass
continue 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: def rename_profile(old_name: str, new_name: str) -> Path:

View File

@ -32,14 +32,14 @@ Honcho provides AI-native cross-session user modeling. It learns who the user is
### Cloud (app.honcho.dev) ### Cloud (app.honcho.dev)
```bash ```bash
hermes honcho setup hermes memory setup honcho
# select "cloud", paste API key from https://app.honcho.dev # select "cloud", paste API key from https://app.honcho.dev
``` ```
### Self-hosted ### Self-hosted
```bash ```bash
hermes honcho setup hermes memory setup honcho
# select "local", enter base URL (e.g. http://localhost:8000) # select "local", enter base URL (e.g. http://localhost:8000)
``` ```

View File

@ -633,7 +633,8 @@ class HindsightMemoryProvider(MemoryProvider):
except Exception: except Exception:
pass pass
existing.update(values) 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: def post_setup(self, hermes_home: str, config: dict) -> None:
"""Custom setup wizard — installs only the deps needed for the selected mode.""" """Custom setup wizard — installs only the deps needed for the selected mode."""

View File

@ -12,8 +12,8 @@ AI-native cross-session user modeling with multi-pass dialectic reasoning, sessi
## Setup ## Setup
```bash ```bash
hermes honcho setup # full interactive wizard (cloud or local) hermes memory setup honcho # configure Honcho directly (works on a fresh install)
hermes memory setup # generic picker, also works hermes memory setup # generic picker, choose Honcho from the list
``` ```
Or manually: Or manually:
@ -22,6 +22,10 @@ hermes config set memory.provider honcho
echo "HONCHO_API_KEY=***" >> ~/.hermes/.env 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 ## Architecture Overview
### Two-Layer Context Injection ### 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) | | 2 | `~/.hermes/honcho.json` | Default profile (shared host blocks) |
| 3 | `~/.honcho/config.json` | Global (cross-app interop) | | 3 | `~/.honcho/config.json` | Global (cross-app interop) |
Host key is derived from the active Hermes profile: `hermes` (default) or `hermes.<profile>`. Host key is derived from the active Hermes profile: `hermes` (default) or `hermes_<profile>`.
For every key, resolution order is: **host block > root > env var > default**. 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.<host>` 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: ""`. **Host vs root semantics.** All three keys are accepted at both root and `hosts.<host>` 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. - **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. - **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", "recallMode": "hybrid",
"sessionStrategy": "per-directory" "sessionStrategy": "per-directory"
}, },
"hermes.coder": { "hermes_coder": {
"aiPeer": "coder", "aiPeer": "coder",
"recallMode": "tools", "recallMode": "tools",
"sessionStrategy": "per-repo" "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. 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.<profile>` (e.g. `hermes -p coder` host key `hermes.coder`). Host key is derived from the active Hermes profile: `hermes` (default) or `hermes_<profile>` (e.g. `hermes -p coder` -> host key `hermes_coder`). Older `hermes.<profile>` host blocks are still read for compatibility and are migrated when the CLI writes profile-scoped Honcho config.
### Dialectic & Reasoning ### Dialectic & Reasoning
@ -307,7 +311,8 @@ Presets:
| Command | Description | | 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 status` | Show resolved config for active profile |
| `hermes honcho enable` / `disable` | Toggle Honcho for active profile | | `hermes honcho enable` / `disable` | Toggle Honcho for active profile |
| `hermes honcho mode <mode>` | Change recall or observation mode | | `hermes honcho mode <mode>` | Change recall or observation mode |
@ -344,7 +349,7 @@ Presets:
"dialecticMaxChars": 600, "dialecticMaxChars": 600,
"saveMessages": true "saveMessages": true
}, },
"hermes.coder": { "hermes_coder": {
"enabled": true, "enabled": true,
"aiPeer": "coder", "aiPeer": "coder",
"sessionStrategy": "per-repo", "sessionStrategy": "per-repo",

View File

@ -249,6 +249,7 @@ class HonchoMemoryProvider(MemoryProvider):
def save_config(self, values, hermes_home): def save_config(self, values, hermes_home):
"""Write config to $HERMES_HOME/honcho.json (Honcho SDK native format).""" """Write config to $HERMES_HOME/honcho.json (Honcho SDK native format)."""
import json import json
import os
from pathlib import Path from pathlib import Path
config_path = Path(hermes_home) / "honcho.json" config_path = Path(hermes_home) / "honcho.json"
existing = {} existing = {}
@ -258,7 +259,8 @@ class HonchoMemoryProvider(MemoryProvider):
except Exception: except Exception:
pass pass
existing.update(values) 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): def get_config_schema(self):
return [ return [

View File

@ -11,7 +11,7 @@ import sys
from pathlib import Path from pathlib import Path
from hermes_constants import get_hermes_home 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 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: if not default_block and not has_key:
return False return False
new_host = f"{HOST}.{profile_name}" new_host = profile_host_key(profile_name)
if new_host in hosts: if new_host in hosts:
return False # already exists return False # already exists
@ -192,7 +192,7 @@ def cmd_sync(args) -> None:
if p.name == "default": if p.name == "default":
continue continue
if clone_honcho_for_profile(p.name): 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 created += 1
else: else:
skipped += 1 skipped += 1
@ -243,7 +243,7 @@ def _host_key() -> str:
if _profile_override: if _profile_override:
if _profile_override in {"default", "custom"}: if _profile_override in {"default", "custom"}:
return HOST return HOST
return f"{HOST}.{_profile_override}" return profile_host_key(_profile_override)
return resolve_active_host() return resolve_active_host()
@ -275,10 +275,8 @@ def _read_config() -> dict:
def _write_config(cfg: dict, path: Path | None = None) -> None: def _write_config(cfg: dict, path: Path | None = None) -> None:
path = path or _local_config_path() path = path or _local_config_path()
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text( from utils import atomic_json_write
json.dumps(cfg, indent=2, ensure_ascii=False) + "\n", atomic_json_write(path, cfg, mode=0o600)
encoding="utf-8",
)
def _resolve_api_key(cfg: dict) -> str: 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 config shapes, e.g. ``localhost:8000``) still pass — the Honcho SDK
will reject them itself with a clearer error than ours. 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", "") key = host_key or cfg.get("apiKey", "") or os.environ.get("HONCHO_API_KEY", "")
if not key: if not key:
base_url = cfg.get("baseUrl") or cfg.get("base_url") or os.environ.get("HONCHO_BASE_URL", "") 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) cfg.pop("base_url", None)
if is_local: 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 "" current_url = cfg.get("baseUrl") or ""
new_url = _prompt("Base URL", default=current_url or "http://localhost:8000") new_url = _prompt("Base URL", default=current_url or "http://localhost:8000")
if new_url: if new_url:
cfg["baseUrl"] = new_url cfg["baseUrl"] = new_url
# For local no-auth, the SDK must not send an API key. # Self-hosted Honcho can run with AUTH_USE_AUTH=true and an
# We keep the key in config (for cloud switching later) but # AUTH_JWT_SECRET on the server side. In that case clients must
# the client should skip auth when baseUrl is local. # send a JWT signed with that secret as the bearer token (the
current_key = cfg.get("apiKey", "") # Honcho SDK takes it via ``api_key=``). Cloud users got prompted
if current_key: # for a key already; the local path historically skipped this and
print(f"\n API key present in config (kept for cloud/hybrid use).") # forced users to disable auth on the server. Offer the prompt
print(" Local connections will skip auth automatically.") # 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: 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: else:
# --- Cloud: set default base URL, require API key --- # --- Cloud: set default base URL, require API key ---
cfg.pop("baseUrl", None) # cloud uses SDK default cfg.pop("baseUrl", None) # cloud uses SDK default

View File

@ -32,6 +32,24 @@ logger = logging.getLogger(__name__)
HOST = "hermes" 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: def resolve_active_host() -> str:
"""Derive the Honcho host key from the active Hermes profile. """Derive the Honcho host key from the active Hermes profile.
@ -47,8 +65,7 @@ def resolve_active_host() -> str:
try: try:
from hermes_cli.profiles import get_active_profile_name from hermes_cli.profiles import get_active_profile_name
profile = get_active_profile_name() profile = get_active_profile_name()
if profile and profile not in {"default", "custom"}: return profile_host_key(profile)
return f"{HOST}.{profile}"
except Exception: except Exception:
pass pass
return HOST return HOST
@ -406,7 +423,7 @@ class HonchoClientConfig:
logger.warning("Failed to read %s: %s, falling back to env", path, e) logger.warning("Failed to read %s: %s, falling back to env", path, e)
return cls.from_env(host=resolved_host) 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 # A hosts.hermes block or explicit enabled flag means the user
# intentionally configured Honcho for this host. # intentionally configured Honcho for this host.
_explicitly_configured = bool(host_block) or raw.get("enabled") is True _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 or "::1" in resolved_base_url
) )
if _is_local: 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 {} _raw = config.raw or {}
_host_block = (_raw.get("hosts") or {}).get(config.host, {}) _host_block = (_raw.get("hosts") or {}).get(config.host, {})
_host_has_key = bool(_host_block.get("apiKey")) _host_has_key = bool(_host_block.get("apiKey"))
@ -819,6 +839,18 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
else: else:
effective_api_key = config.api_key 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 = { kwargs: dict = {
"workspace_id": config.workspace_id, "workspace_id": config.workspace_id,
"api_key": effective_api_key, "api_key": effective_api_key,

View File

@ -155,7 +155,8 @@ class Mem0MemoryProvider(MemoryProvider):
except Exception: except Exception:
pass pass
existing.update(values) 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): def get_config_schema(self):
return [ return [

View File

@ -152,7 +152,8 @@ def _save_supermemory_config(values: dict, hermes_home: str) -> None:
except Exception: except Exception:
existing = {} existing = {}
existing.update(values) 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: def _detect_category(text: str) -> str:

View File

@ -276,6 +276,111 @@ class TestExtractCacheBustingConfig:
assert out["tools.registry_generation"] == 12345 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): def test_full_round_trip_busts_cache_on_real_edit(self):
"""End-to-end: simulate a config edit on main and verify the """End-to-end: simulate a config edit on main and verify the
extracted cache_keys change produces a new signature.""" extracted cache_keys change produces a new signature."""

View File

@ -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

View File

@ -754,8 +754,8 @@ class TestRenameProfile:
cfg = json.loads(honcho_path.read_text()) cfg = json.loads(honcho_path.read_text())
assert "hermes.ssi_health" not in cfg["hosts"] assert "hermes.ssi_health" not in cfg["hosts"]
assert cfg["hosts"]["hermes.heimdall"]["aiPeer"] == "ssi_health" assert cfg["hosts"]["hermes_heimdall"]["aiPeer"] == "ssi_health"
assert cfg["hosts"]["hermes.heimdall"]["peerName"] == "user-peer" assert cfg["hosts"]["hermes_heimdall"]["peerName"] == "user-peer"
def test_pins_ai_peer_when_absent_on_honcho_host_rename(self, profile_env): def test_pins_ai_peer_when_absent_on_honcho_host_rename(self, profile_env):
tmp_path = profile_env tmp_path = profile_env
@ -772,8 +772,8 @@ class TestRenameProfile:
cfg = json.loads(honcho_path.read_text()) cfg = json.loads(honcho_path.read_text())
assert "hermes.ssi_health" not in cfg["hosts"] assert "hermes.ssi_health" not in cfg["hosts"]
assert cfg["hosts"]["hermes.heimdall"]["aiPeer"] == "ssi_health" assert cfg["hosts"]["hermes_heimdall"]["aiPeer"] == "ssi_health"
assert cfg["hosts"]["hermes.heimdall"]["workspace"] == "hermes" assert cfg["hosts"]["hermes_heimdall"]["workspace"] == "hermes"
def test_does_not_overwrite_existing_honcho_host_on_rename(self, profile_env): def test_does_not_overwrite_existing_honcho_host_on_rename(self, profile_env):
tmp_path = profile_env tmp_path = profile_env
@ -782,7 +782,7 @@ class TestRenameProfile:
honcho_path.write_text(json.dumps({ honcho_path.write_text(json.dumps({
"hosts": { "hosts": {
"hermes.ssi_health": {"aiPeer": "ssi_health"}, "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()) cfg = json.loads(honcho_path.read_text())
assert cfg["hosts"]["hermes.ssi_health"]["aiPeer"] == "ssi_health" 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): def test_default_raises_value_error(self, profile_env):
with pytest.raises(ValueError, match="default"): with pytest.raises(ValueError, match="default"):

View File

@ -1,6 +1,7 @@
"""Tests for plugins/memory/honcho/cli.py.""" """Tests for plugins/memory/honcho/cli.py."""
from types import SimpleNamespace from types import SimpleNamespace
import json
class TestResolveApiKey: class TestResolveApiKey:
@ -100,6 +101,84 @@ class TestResolveApiKey:
f"expected local sentinel for legacy schemeless {legacy!r}" 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: class TestCmdStatus:
def test_reports_connection_failure_when_session_setup_fails(self, monkeypatch, capsys, tmp_path): def test_reports_connection_failure_when_session_setup_fails(self, monkeypatch, capsys, tmp_path):
import plugins.memory.honcho.cli as honcho_cli 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) honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)
ok = honcho_cli.clone_honcho_for_profile("coder") ok = honcho_cli.clone_honcho_for_profile("coder")
assert ok is True 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"} assert new_block["userPeerAliases"] == {"86701400": "eri", "discord-491827364": "eri"}
def test_runtime_peer_prefix_carries_into_cloned_profile(self, monkeypatch, tmp_path): 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) honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)
ok = honcho_cli.clone_honcho_for_profile("coder") ok = honcho_cli.clone_honcho_for_profile("coder")
assert ok is True assert ok is True
new_block = written["cfg"]["hosts"]["hermes.coder"] new_block = written["cfg"]["hosts"]["hermes_coder"]
assert new_block["runtimePeerPrefix"] == "telegram_" assert new_block["runtimePeerPrefix"] == "telegram_"
def test_pin_peer_name_carries_into_cloned_profile(self, monkeypatch, tmp_path): 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) honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)
ok = honcho_cli.clone_honcho_for_profile("coder") ok = honcho_cli.clone_honcho_for_profile("coder")
assert ok is True assert ok is True
new_block = written["cfg"]["hosts"]["hermes.coder"] new_block = written["cfg"]["hosts"]["hermes_coder"]
assert new_block["pinPeerName"] is True assert new_block["pinPeerName"] is True
def test_unset_identity_keys_do_not_appear_in_cloned_profile(self, monkeypatch, tmp_path): 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) honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)
ok = honcho_cli.clone_honcho_for_profile("coder") ok = honcho_cli.clone_honcho_for_profile("coder")
assert ok is True 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 "userPeerAliases" not in new_block
assert "runtimePeerPrefix" not in new_block assert "runtimePeerPrefix" not in new_block
assert "pinPeerName" not in new_block assert "pinPeerName" not in new_block
@ -572,5 +651,5 @@ class TestCloneCarriesPinUserPeer:
ok = honcho_cli.clone_honcho_for_profile("partner") ok = honcho_cli.clone_honcho_for_profile("partner")
assert ok is True assert ok is True
new_block = written["cfg"]["hosts"]["hermes.partner"] new_block = written["cfg"]["hosts"]["hermes_partner"]
assert new_block["pinUserPeer"] is True assert new_block["pinUserPeer"] is True

View File

@ -13,6 +13,7 @@ import pytest
from plugins.memory.honcho.client import ( from plugins.memory.honcho.client import (
HonchoClientConfig, HonchoClientConfig,
get_honcho_client, get_honcho_client,
profile_host_key,
reset_honcho_client, reset_honcho_client,
resolve_active_host, resolve_active_host,
resolve_config_path, resolve_config_path,
@ -430,6 +431,10 @@ class TestResolveConfigPath:
class TestResolveActiveHost: 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): def test_default_returns_hermes(self):
with patch.dict(os.environ, {}, clear=True): with patch.dict(os.environ, {}, clear=True):
os.environ.pop("HERMES_HONCHO_HOST", None) os.environ.pop("HERMES_HONCHO_HOST", None)
@ -444,7 +449,7 @@ class TestResolveActiveHost:
with patch.dict(os.environ, {}, clear=False): with patch.dict(os.environ, {}, clear=False):
os.environ.pop("HERMES_HONCHO_HOST", None) os.environ.pop("HERMES_HONCHO_HOST", None)
with patch("hermes_cli.profiles.get_active_profile_name", return_value="coder"): 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): def test_default_profile_returns_hermes(self):
with patch.dict(os.environ, {}, clear=False): with patch.dict(os.environ, {}, clear=False):
@ -477,10 +482,10 @@ class TestResolveActiveHost:
class TestProfileScopedConfig: class TestProfileScopedConfig:
def test_from_env_uses_profile_host(self): def test_from_env_uses_profile_host(self):
with patch.dict(os.environ, {"HONCHO_API_KEY": "key"}): with patch.dict(os.environ, {"HONCHO_API_KEY": "key"}):
config = HonchoClientConfig.from_env(host="hermes.coder") config = HonchoClientConfig.from_env(host="hermes_coder")
assert config.host == "hermes.coder" assert config.host == "hermes_coder"
assert config.workspace_id == "hermes" # shared workspace 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): def test_from_env_default_workspace_preserved_for_default_host(self):
with patch.dict(os.environ, {"HONCHO_API_KEY": "key"}): with patch.dict(os.environ, {"HONCHO_API_KEY": "key"}):
@ -494,22 +499,35 @@ class TestProfileScopedConfig:
"apiKey": "shared-key", "apiKey": "shared-key",
"hosts": { "hosts": {
"hermes": {"aiPeer": "hermes", "peerName": "alice"}, "hermes": {"aiPeer": "hermes", "peerName": "alice"},
"hermes.coder": { "hermes_coder": {
"aiPeer": "hermes.coder", "aiPeer": "hermes_coder",
"peerName": "alice-coder", "peerName": "alice-coder",
"workspace": "coder-ws", "workspace": "coder-ws",
}, },
}, },
})) }))
config = HonchoClientConfig.from_global_config( 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.workspace_id == "coder-ws"
assert config.ai_peer == "hermes.coder" assert config.ai_peer == "hermes_coder"
assert config.peer_name == "alice-coder" assert config.peer_name == "alice-coder"
def test_from_global_config_auto_resolves_host(self, tmp_path): 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 = tmp_path / "config.json"
config_file.write_text(json.dumps({ config_file.write_text(json.dumps({
"apiKey": "key", "apiKey": "key",
@ -517,10 +535,13 @@ class TestProfileScopedConfig:
"hermes.dreamer": {"peerName": "dreamer-user"}, "hermes.dreamer": {"peerName": "dreamer-user"},
}, },
})) }))
with patch("plugins.memory.honcho.client.resolve_active_host", return_value="hermes.dreamer"): config = HonchoClientConfig.from_global_config(
config = HonchoClientConfig.from_global_config(config_path=config_file) host="hermes_dreamer",
assert config.host == "hermes.dreamer" config_path=config_file,
)
assert config.host == "hermes_dreamer"
assert config.peer_name == "dreamer-user" assert config.peer_name == "dreamer-user"
assert config.workspace_id == "hermes_dreamer"
class TestObservationModeMigration: class TestObservationModeMigration:
@ -890,3 +911,176 @@ class TestDialecticDepthParsing:
})) }))
config = HonchoClientConfig.from_global_config(config_path=config_file) config = HonchoClientConfig.from_global_config(config_path=config_file)
assert config.dialectic_depth_levels == ["low", "high"] 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}"
)

View File

@ -745,10 +745,10 @@ class TestPinTransition:
monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor", "pinPeerName": True})) 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})) 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"] 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)) monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor"})) 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({ cfg_path.write_text(json.dumps({
"apiKey": "k", "apiKey": "k",
"peerName": "Igor", "peerName": "Igor",
"userPeerAliases": {"86701400": "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"] 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)) monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor"})) 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({ cfg_path.write_text(json.dumps({
"apiKey": "k", "apiKey": "k",
"peerName": "Igor", "peerName": "Igor",
"runtimePeerPrefix": "telegram_", "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"] assert sig_no_prefix["honcho.runtime_peer_prefix"] != sig_with_prefix["honcho.runtime_peer_prefix"]
@ -805,14 +805,14 @@ class TestPinTransition:
"peerName": "Igor", "peerName": "Igor",
"aiPeer": "hermes", "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({ cfg_path.write_text(json.dumps({
"apiKey": "k", "apiKey": "k",
"peerName": "Igor", "peerName": "Igor",
"aiPeer": "hermetika", "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"] assert sig_before["honcho.ai_peer"] != sig_after["honcho.ai_peer"]

View File

@ -6,7 +6,9 @@ turn counting, tags), and schema completeness.
""" """
import json import json
import os
import re import re
import stat
import sys import sys
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
@ -1570,3 +1572,13 @@ class TestShutdown:
assert embedded._client is None assert embedded._client is None
assert provider._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)}"

View File

@ -4,6 +4,10 @@ Salvaged from PRs #5301 (qaqcvc) and #5117 (vvvanguards).
""" """
import json import json
import os
import stat
import pytest
from plugins.memory.mem0 import Mem0MemoryProvider 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: class TestMem0Defaults:
"""Ensure we don't break existing users' defaults.""" """Ensure we don't break existing users' defaults."""

View File

@ -1,4 +1,6 @@
import json import json
import os
import stat
import threading import threading
import pytest import pytest
@ -409,3 +411,13 @@ def test_get_config_schema_minimal():
assert len(schema) == 1 assert len(schema) == 1
assert schema[0]["key"] == "api_key" assert schema[0]["key"] == "api_key"
assert schema[0]["secret"] is True 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)}"

View File

@ -2,9 +2,13 @@
import json import json
import os import os
import stat
from pathlib import Path
import pytest
from plugins.memory.honcho.client import HonchoClientConfig from plugins.memory.honcho.client import HonchoClientConfig
from plugins.memory.honcho import HonchoMemoryProvider
class TestHonchoClientConfigAutoEnable: class TestHonchoClientConfigAutoEnable:
@ -100,3 +104,24 @@ class TestHonchoClientConfigAutoEnable:
assert cfg.api_key == "fallback-key" assert cfg.api_key == "fallback-key"
assert cfg.enabled is True # from_env() sets enabled=True 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)}"

View File

@ -87,6 +87,7 @@ def atomic_json_write(
data: Any, data: Any,
*, *,
indent: int = 2, indent: int = 2,
mode: int | None = None,
**dump_kwargs: Any, **dump_kwargs: Any,
) -> None: ) -> None:
"""Write JSON data to a file atomically. """Write JSON data to a file atomically.
@ -99,13 +100,16 @@ def atomic_json_write(
path: Target file path (will be created or overwritten). path: Target file path (will be created or overwritten).
data: JSON-serializable data to write. data: JSON-serializable data to write.
indent: JSON indentation (default 2). 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 **dump_kwargs: Additional keyword args forwarded to json.dump(), such
as default=str for non-native types. as default=str for non-native types.
""" """
path = Path(path) path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True) 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( fd, tmp_path = tempfile.mkstemp(
dir=str(path.parent), dir=str(path.parent),
@ -113,6 +117,8 @@ def atomic_json_write(
suffix=".tmp", suffix=".tmp",
) )
try: try:
if mode is not None:
os.fchmod(fd, mode)
with os.fdopen(fd, "w", encoding="utf-8") as f: with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump( json.dump(
data, data,
@ -125,7 +131,13 @@ def atomic_json_write(
os.fsync(f.fileno()) os.fsync(f.fileno())
# Preserve symlinks — swap in-place on the real file (GitHub #16743). # Preserve symlinks — swap in-place on the real file (GitHub #16743).
real_path = atomic_replace(tmp_path, path) 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: except BaseException:
# Intentionally catch BaseException so temp-file cleanup still runs for # Intentionally catch BaseException so temp-file cleanup still runs for
# KeyboardInterrupt/SystemExit before re-raising the original signal. # KeyboardInterrupt/SystemExit before re-raising the original signal.

View File

@ -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. 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.<host>.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 ### Full Config Reference
| Key | Default | Description | | Key | Default | Description |
@ -199,11 +203,12 @@ When Honcho is active as the memory provider, five tools become available:
## CLI Commands ## 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 ```bash
hermes memory setup honcho # Configure Honcho directly (works before activation)
hermes honcho status # Connection status, config, and key settings 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 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 peer # Show or update peer names + dialectic reasoning level
hermes honcho mode # Show or set recall mode (hybrid/context/tools) hermes honcho mode # Show or set recall mode (hybrid/context/tools)

View File

@ -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 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). **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).