Honcho self-hosted setup JWT authentication support

NousResearch/hermes-agent · cluster 29885 · hermes cli

Data as of

Nothing open on GitHub. Fix merged in #35170. Check it covers your case.

The short answer

0open issues
0open PRs
5items grouped
Known?
Yes. 5 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #35170. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #35170. That is separate from the fix event.
Still open
Nothing open on GitHub.

These items concern adding and hardening JWT secret configuration for self-hosted (local) Honcho instances, including prompting users for credentials during setup. The work spans feature requests, implementation, and path-hardening fixes.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #35170 fix(honcho): harden self-hosted setup paths

    merged to main

    files: gateway/run.py, hermes_cli/main.py, hermes_cli/memory_setup.py, hermes_cli/profiles.py, optional-skills/autonomous-ai-agents/honcho/SKILL.md, plugins/memory/hindsight/__init__.py, plugins/memory/honcho/README.md, plugins/memory/honcho/__init__.py, plugins/memory/honcho/cli.py, plugins/memory/honcho/client.py, plugins/memory/mem0/__init__.py, plugins/memory/supermemory/__init__.py, tests/gateway/test_agent_cache.py, tests/hermes_cli/test_memory_setup_provider_arg.py, tests/hermes_cli/test_profiles.py, tests/honcho_plugin/test_cli.py, tests/honcho_plugin/test_client.py, tests/honcho_plugin/test_pin_peer_name.py, tests/plugins/memory/test_hindsight_provider.py, tests/plugins/memory/test_mem0_v2.py (+5 more)

    Show the diff
    • gateway/run.py
      @@ -15331,8 +15331,52 @@ async def _run_process_watcher(self, watcher: dict) -> None:
               ("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 @@ def _extract_cache_busting_config(cls, user_config: dict | None) -> dict:
                   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
       
    • hermes_cli/main.py
      @@ -13029,9 +13029,15 @@ def cmd_plugins(args):
               ),
           )
           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(
    • 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:
    • 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:
    • 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)
       ```
       
    • plugins/memory/hindsight/__init__.py
      @@ -633,7 +633,8 @@ def save_config(self, values, hermes_home):
                   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."""
    • 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.<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",
    • plugins/memory/honcho/__init__.py
      @@ -249,6 +249,7 @@ def is_available(self) -> bool:
           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 @@ def save_config(self, values, hermes_home):
                   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 [
    • plugins/memory/honcho/cli.py
      @@ -11,7 +11,7 @@
       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
    • plugins/memory/honcho/client.py
      @@ -32,6 +32,24 @@
       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 @@ def from_global_config(
                   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 = [redacted:secret-assignment]
       
      +    # 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,
    • plugins/memory/mem0/__init__.py
      @@ -155,7 +155,8 @@ def save_config(self, values, hermes_home):
                   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 [
    • 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:
    • tests/gateway/test_agent_cache.py
      @@ -276,6 +276,111 @@ def test_extract_includes_live_tool_registry_generation(self, monkeypatch):
       
               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."""
    • 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 <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
    • tests/hermes_cli/test_profiles.py
      @@ -754,8 +754,8 @@ def test_renames_root_honcho_host_without_changing_ai_peer(self, profile_env):
       
               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 @@ def test_pins_ai_peer_when_absent_on_honcho_host_rename(self, profile_env):
       
               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 @@ def test_does_not_overwrite_existing_honcho_host_on_rename(self, profile_env):
               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 @@ def test_does_not_overwrite_existing_honcho_host_on_rename(self, profile_env):
       
               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"):
    • 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 @@ def test_accepts_legacy_schemeless_host(self, monkeypatch):
                       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=[redacted:secret-assignment]
      +            if not answers:
      +                # Default-through any remaining prompts to keep the wizard moving.
      +                return default or ""
      +            return answers.pop(0)
      +
      +        monkeypatch.setattr(honcho_cli, "_prompt", _fake_prompt)
      +
      +        honcho_cli.cmd_setup(SimpleNamespace())
      +        return written.get("cfg")
      +
      +    def test_local_setup_stores_jwt_under_host_block(self, monkeypatch, tmp_path):
      +        """Self-hosted users supplying a JWT must have it written under hosts.<host>.apiKey,
      +        not as the top-level cloud apiKey, so cloud/hybrid switching is preserved and
      +        get_honcho_client treats it as an explicit local auth opt-in."""
      +        cfg = self._run_setup(
      +            monkeypatch,
      +            tmp_path,
      +            initial_cfg={},
      +            prompt_answers=[
      +                "local",                       # deployment
      +                "http://localhost:8000",       # base URL
      +                "my-local-jwt-token",          # local JWT
      +            ],
      +        )
      +        assert cfg is not None
      +        assert cfg.get("baseUrl") == "http://localhost:8000"
      +        # Top-level apiKey must remain unset (cloud field).
      +        assert not cfg.get("apiKey")
      +        # The new local JWT belongs under the host block.
      +        host_block = (cfg.get("hosts") or {}).get("hermes") or {}
      +        assert host_block.get("apiKey") == "my-local-jwt-token"
      +
      +    def test_local_setup_blank_jwt_keeps_local_no_auth(self, monkeypatch, tmp_path):
      +        """Blank JWT prompt response on a fresh local config must not introduce an apiKey
      +        anywhere (local no-auth Honcho deployments must still work out of the box)."""
      +        cfg = self._run_setup(
      +            monkeypatch,
      +            tmp_path,
      +            initial_cfg={},
      +            prompt_answers=[
      +                "local",
      +                "http://localhost:8000",
      +                "",  # blank JWT
      +            ],
      +        )
      +        assert cfg is not None
      +        assert cfg.get("baseUrl") == "http://localhost:8000"
      +        assert not cfg.get("apiKey")
      +        host_block = (cfg.get("hosts") or {}).get("hermes") or {}
      +        assert not host_block.get("apiKey")
      +
      +
       class TestCmdStatus:
           def test_reports_connection_failure_when_session_setup_fails(self, monkeypatch, capsys, tmp_path):
               import plugins.memory.honcho.cli as honcho_cli
      @@ -192,7 +271,7 @@ def test_user_peer_aliases_carry_into_cloned_profile(self, monkeypatch, tmp_path
               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 @@ def test_runtime_peer_prefix_carries_into_cloned_profile(self, monkeypatch, tmp_
               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 @@ def test_pin_peer_name_carries_into_cloned_profile(self, monkeypatch, tmp_path):
               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 @@ def test_unset_identity_keys_do_not_appear_in_cloned_profile(self, monkeypatch,
               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 @@ def test_clone_inherits_host_pin_user_peer(self, monkeypatch, tmp_path):
       
               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
    • tests/honcho_plugin/test_client.py
      @@ -13,6 +13,7 @@
       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 @@ def test_from_global_config_uses_local_path(self, tmp_path):
       
       
       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 @@ def test_profile_name_derives_host(self):
               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 @@ def test_profiles_import_failure_falls_back(self):
       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,19 +499,19 @@ def test_from_global_config_reads_profile_host_block(self, tmp_path):
                   "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):
      @@ -514,14 +519,30 @@ def test_from_global_config_auto_resolves_host(self, tmp_path):
               config_file.write_text(json.dumps({
                   "apiKey": "key",
                   "hosts": {
      -                "hermes.dreamer": {"peerName": "dreamer-user"},
      +                "hermes_dreamer": {"peerName": "dreamer-user"},
                   },
               }))
      -        with patch("plugins.memory.honcho.client.resolve_active_host", return_value="hermes.dreamer"):
      +        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.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",
      +            "hosts": {
      +                "hermes.dreamer": {"peerName": "dreamer-user"},
      +            },
      +        }))
      +        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:
           """Existing configs without explicit observationMode keep 'unified' default."""
      @@ -890,3 +911,176 @@ def test_depth_levels_invalid_values_default_to_low(self, tmp_path):
               }))
               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="[redacted:secret-assignment]",
      +            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="[redacted:secret-assignment]",
      +            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="[redacted:secret-assignment]",
      +            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}"
      +        )
    • tests/honcho_plugin/test_pin_peer_name.py
      @@ -745,10 +745,10 @@ def test_cache_busting_signature_reflects_pin_peer_name(self, tmp_path, monkeypa
               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 @@ def test_cache_busting_signature_reflects_user_peer_aliases(self, tmp_path, monk
               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 @@ def test_cache_busting_signature_reflects_runtime_peer_prefix(self, tmp_path, mo
               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 @@ def test_cache_busting_signature_reflects_ai_peer(self, tmp_path, monkeypatch):
                   "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"]
       
    • tests/plugins/memory/test_hindsight_provider.py
      @@ -6,7 +6,9 @@
       """
       
       import json
      +import os
       import re
      +import stat
       import sys
       from types import SimpleNamespace
       from unittest.mock import AsyncMock, MagicMock
      @@ -1570,3 +1572,13 @@ def test_local_embedded_shutdown_closes_inner_async_client_on_shared_loop(self,
               assert embedded._client is None
               assert provider._client is None
       
      +
      [redacted:email](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)}"
    • tests/plugins/memory/test_mem0_v2.py
      @@ -4,6 +4,10 @@
       """
       
       import json
      +import os
      +import stat
      +
      +import pytest
       
       from plugins.memory.mem0 import Mem0MemoryProvider
       
      @@ -202,6 +206,17 @@ def test_prefetch_dict_response(self, monkeypatch):
       # ---------------------------------------------------------------------------
       
       
      [redacted:email](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."""
       
    • 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
      +
      +
      [redacted:email](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)}"
    • 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 @@ def test_falls_back_to_env_when_no_config_file(self, tmp_path, monkeypatch):
       
               assert cfg.api_key == "fallback-key"
               assert cfg.enabled is True  # from_env() sets enabled=True
      +
      +
      [redacted:email](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)}"
    • 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.
    • 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.<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
       
       | 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)
    • 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).
       

2 PRs closed without merging

From the close comments: done elsewhere 1 · merged via another change 1

  • #30180 feat(honcho): prompt for local JWT during self-hosted setup · done elsewhere → 827ce60, #29885
  • #33954 fix(honcho): harden self-hosted setup paths · merged via another change → 827ce60, #35170, #34851
Closed issues · 2
  • Closed#29885[Feature]: Honcho local host support - JWT support
  • Closed#29886Support AUTH_JWT_SECRET for local (self-hosted) Honcho instances
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.34: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#35170
Closed issues
2
Attempts
2 PRs closed without merging
Fix link checked
30 May 2026

For your agent

The same facts, typed.