fix(honcho): harden self-hosted setup paths

Self-hosted Honcho setup had four sharp edges:

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

This keeps existing behavior but makes those paths safer:

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

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

Co-authored-by: BROCCOLO1D
This commit is contained in:
Erosika
2026-05-29 22:29:48 -07:00
committed by kshitij
co-authored by BROCCOLO1D
parent aa32edcac5
commit 827ce602db
25 changed files with 734 additions and 101 deletions
+2 -1
View File
@@ -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."""
+13 -8
View File
@@ -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.<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**.
@@ -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: ""`.
**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.<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
@@ -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 <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",
+3 -1
View File
@@ -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 [
+53 -18
View File
@@ -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
+36 -4
View File
@@ -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,
+2 -1
View File
@@ -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 [
+2 -1
View File
@@ -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: