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:
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 [
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 [
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
50
tests/hermes_cli/test_memory_setup_provider_arg.py
Normal file
50
tests/hermes_cli/test_memory_setup_provider_arg.py
Normal 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
|
||||||
@ -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"):
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|
||||||
|
|||||||
@ -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)}"
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|
||||||
|
|||||||
@ -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)}"
|
||||||
|
|||||||
@ -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)}"
|
||||||
|
|||||||
16
utils.py
16
utils.py
@ -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.
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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).
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user