diff --git a/agent/agent_init.py b/agent/agent_init.py index 3a648c1b955..4f2e3f138f7 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1156,6 +1156,9 @@ def init_agent( "hermes_home": str(get_hermes_home()), "agent_context": "primary", } + if _init_kwargs["platform"] == "cli": + _init_kwargs["warning_callback"] = agent._emit_warning + _init_kwargs["status_callback"] = agent._emit_status # Thread session title for memory provider scoping # (e.g. honcho uses this to derive chat-scoped session keys) if agent._session_db: diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index 2707c77f4ff..c1b058adaeb 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -15,24 +15,50 @@ from pathlib import Path from hermes_constants import get_hermes_home from hermes_cli.secret_prompt import masked_secret_prompt +_CANCELLED = -1 + # --------------------------------------------------------------------------- # Curses-based interactive picker (same pattern as hermes tools) # --------------------------------------------------------------------------- -def _curses_select(title: str, items: list[tuple[str, str]], default: int = 0) -> int: +def _curses_select( + title: str, + items: list[tuple[str, str]], + default: int = 0, + *, + cancel_returns: int | None = None, +) -> int: """Interactive single-select with arrow keys. items: list of (label, description) tuples. - Returns selected index, or default on escape/quit. + Returns selected index, or cancel_returns/default on escape/quit. """ from hermes_cli.curses_ui import curses_radiolist + + if cancel_returns is None: + cancel_returns = default + # Format (label, desc) tuples into display strings display_items = [ - f"{label} {desc}" if desc else label + f"{label} - {desc}" if desc else label for label, desc in items ] - return curses_radiolist(title, display_items, selected=default, cancel_returns=default) + result = curses_radiolist(title, display_items, selected=default, cancel_returns=cancel_returns) + _clear_interactive_transition() + return result + + +def _print_cancelled_setup() -> None: + print("\n Cancelled. No changes saved.\n") + + +def _clear_interactive_transition() -> None: + """Clear stale curses content before entering a follow-up setup screen.""" + if not sys.stdout.isatty(): + return + sys.stdout.write("\033[2J\033[H") + sys.stdout.flush() def _prompt(label: str, default: str | None = None, secret: bool = False) -> str: @@ -205,6 +231,8 @@ def cmd_setup_provider(provider_name: str) -> None: name, _, provider = match + _clear_interactive_transition() + _install_dependencies(name) config = load_config() @@ -241,14 +269,17 @@ def cmd_setup(args) -> None: items.append(("Built-in only", "— MEMORY.md / USER.md (default)")) builtin_idx = len(items) - 1 - selected = _curses_select("Memory provider setup", items, default=builtin_idx) + selected = _curses_select("Memory provider setup", items, default=builtin_idx, cancel_returns=_CANCELLED) + if selected == _CANCELLED: + _print_cancelled_setup() + return config = load_config() if not isinstance(config.get("memory"), dict): config["memory"] = {} # Built-in only - if selected >= len(providers) or selected < 0: + if selected >= len(providers): config["memory"]["provider"] = "" save_config(config) print("\n ✓ Memory provider: built-in only") @@ -257,6 +288,8 @@ def cmd_setup(args) -> None: name, _, provider = providers[selected] + _clear_interactive_transition() + # Install pip dependencies if declared in plugin.yaml _install_dependencies(name) @@ -309,7 +342,10 @@ def cmd_setup(args) -> None: current_idx = 0 if current and current in choices: current_idx = choices.index(current) - sel = _curses_select(f" {desc}", choice_items, default=current_idx) + sel = _curses_select(f" {desc}", choice_items, default=current_idx, cancel_returns=_CANCELLED) + if sel == _CANCELLED: + _print_cancelled_setup() + return provider_config[key] = choices[sel] elif is_secret: # Prompt for secret @@ -407,43 +443,53 @@ def cmd_status(args) -> None: print(f" Built-in: always active") print(f" Provider: {provider_name or '(none — built-in only)'}") + providers = _get_available_providers() + provider = None + for pname, _, candidate in providers: + if pname == provider_name: + provider = candidate + break + if provider_name: provider_config = mem_config.get(provider_name, {}) - if provider_config: + display_config = provider_config + if provider and hasattr(provider, "get_status_config"): + try: + display_config = provider.get_status_config(provider_config) + except Exception as e: + display_config = dict(provider_config) if isinstance(provider_config, dict) else provider_config + if isinstance(display_config, dict): + display_config["status_config_error"] = str(e) + + if display_config: print(f"\n {provider_name} config:") - for key, val in provider_config.items(): + for key, val in display_config.items(): print(f" {key}: {val}") - providers = _get_available_providers() - found = any(name == provider_name for name, _, _ in providers) - if found: + if provider: print(f"\n Plugin: installed ✓") - for pname, _, p in providers: - if pname == provider_name: - if p.is_available(): - print(f" Status: available ✓") - else: - print(f" Status: not available ✗") - schema = p.get_config_schema() if hasattr(p, "get_config_schema") else [] - # Check all fields that have env_var (both secret and non-secret) - required_fields = [f for f in schema if f.get("env_var")] - if required_fields: - print(f" Missing:") - for f in required_fields: - env_var = f.get("env_var", "") - url = f.get("url", "") - is_set = bool(os.environ.get(env_var)) - mark = "✓" if is_set else "✗" - line = f" {mark} {env_var}" - if url and not is_set: - line += f" → {url}" - print(line) - break + if provider.is_available(): + print(f" Status: available ✓") + else: + print(f" Status: not available ✗") + schema = provider.get_config_schema() if hasattr(provider, "get_config_schema") else [] + # Check all fields that have env_var (both secret and non-secret) + required_fields = [f for f in schema if f.get("env_var")] + if required_fields: + print(f" Missing:") + for f in required_fields: + env_var = f.get("env_var", "") + url = f.get("url", "") + is_set = bool(os.environ.get(env_var)) + mark = "✓" if is_set else "✗" + line = f" {mark} {env_var}" + if url and not is_set: + line += f" → {url}" + print(line) else: print(f"\n Plugin: NOT installed ✗") print(f" Install the '{provider_name}' memory plugin to ~/.hermes/plugins/") - providers = _get_available_providers() if providers: print(f"\n Installed plugins:") for pname, desc, _ in providers: diff --git a/hermes_cli/secret_prompt.py b/hermes_cli/secret_prompt.py index d1cffc34c5e..1f8a4df485a 100644 --- a/hermes_cli/secret_prompt.py +++ b/hermes_cli/secret_prompt.py @@ -27,16 +27,16 @@ def _collect_masked_input( while True: ch = read_char() if ch == "": - write("\n") + write("\r\n") raise EOFError if ch in _ENTER_CHARS: - write("\n") + write("\r\n") return "".join(value) if ch == "\x03": - write("\n") + write("\r\n") raise KeyboardInterrupt if ch in _EOF_CHARS: - write("\n") + write("\r\n") raise EOFError if ch in _BACKSPACE_CHARS: if value: diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index c26e45a0e11..03ebda28eca 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -702,7 +702,7 @@ class HindsightMemoryProvider(MemoryProvider): from hermes_cli.config import save_config from hermes_cli.secret_prompt import masked_secret_prompt - from hermes_cli.memory_setup import _curses_select + from hermes_cli.memory_setup import _CANCELLED, _curses_select, _print_cancelled_setup print("\n Configuring Hindsight memory:\n") @@ -719,7 +719,10 @@ class HindsightMemoryProvider(MemoryProvider): ] existing_mode = existing_config.get("mode") mode_default_idx = mode_values.index(existing_mode) if existing_mode in mode_values else 0 - mode_idx = _curses_select(" Select mode", mode_items, default=mode_default_idx) + mode_idx = _curses_select(" Select mode", mode_items, default=mode_default_idx, cancel_returns=_CANCELLED) + if mode_idx == _CANCELLED: + _print_cancelled_setup() + return mode = mode_values[mode_idx] provider_config: dict = dict(existing_config) @@ -737,6 +740,27 @@ class HindsightMemoryProvider(MemoryProvider): else: deps_to_install = [cloud_dep] + llm_provider = "" + if mode == "local_embedded": + providers_list = list(_PROVIDER_DEFAULT_MODELS.keys()) + llm_items = [ + (p, f"default model: {_PROVIDER_DEFAULT_MODELS[p]}") + for p in providers_list + ] + existing_llm_provider = provider_config.get("llm_provider") + llm_default_idx = providers_list.index(existing_llm_provider) if existing_llm_provider in providers_list else 0 + llm_idx = _curses_select( + " Select LLM provider", + llm_items, + default=llm_default_idx, + cancel_returns=_CANCELLED, + ) + if llm_idx == _CANCELLED: + _print_cancelled_setup() + return + llm_provider = providers_list[llm_idx] + provider_config["llm_provider"] = llm_provider + print("\n Checking dependencies...") uv_path = shutil.which("uv") if not uv_path: @@ -785,18 +809,6 @@ class HindsightMemoryProvider(MemoryProvider): env_writes["HINDSIGHT_API_KEY"] = api_key else: # local_embedded - providers_list = list(_PROVIDER_DEFAULT_MODELS.keys()) - llm_items = [ - (p, f"default model: {_PROVIDER_DEFAULT_MODELS[p]}") - for p in providers_list - ] - existing_llm_provider = provider_config.get("llm_provider") - llm_default_idx = providers_list.index(existing_llm_provider) if existing_llm_provider in providers_list else 0 - llm_idx = _curses_select(" Select LLM provider", llm_items, default=llm_default_idx) - llm_provider = providers_list[llm_idx] - - provider_config["llm_provider"] = llm_provider - if llm_provider == "openai_compatible": existing_base_url = provider_config.get("llm_base_url", "") prompt = " LLM endpoint URL (e.g. http://192.168.1.10:8080/v1)" diff --git a/plugins/memory/openviking/README.md b/plugins/memory/openviking/README.md index 07e9484d4dd..0b6be37c0a7 100644 --- a/plugins/memory/openviking/README.md +++ b/plugins/memory/openviking/README.md @@ -14,6 +14,10 @@ Context database by Volcengine (ByteDance) with filesystem-style knowledge hiera hermes memory setup # select "openviking" ``` +The setup can link to an existing `~/.openviking/ovcli.conf`, copy its current +connection values into Hermes, or create a minimal `ovcli.conf` when one does +not exist. + Or manually: ```bash hermes config set memory.provider openviking @@ -28,6 +32,9 @@ All config via environment variables in `.env`: |---------|---------|-------------| | `OPENVIKING_ENDPOINT` | `http://127.0.0.1:1933` | Server URL | | `OPENVIKING_API_KEY` | (none) | API key (optional) | +| `OPENVIKING_ACCOUNT` | (none) | Tenant account override | +| `OPENVIKING_USER` | (none) | Tenant user override | +| `OPENVIKING_AGENT` | `hermes` | Tenant agent namespace | ## Tools diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index da7a10a9f13..955ee48eb2e 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -7,12 +7,13 @@ automatic memory extraction, and session management. Original PR #3369 by Mibayy, rewritten to use the full OpenViking session lifecycle instead of read-only search endpoints. -Config via environment variables (profile-scoped via each profile's .env): +Config via environment variables (profile-scoped via each profile's .env) +or a linked OpenViking CLI config: OPENVIKING_ENDPOINT — Server URL (default: http://127.0.0.1:1933) OPENVIKING_API_KEY — API key (required for authenticated servers) - OPENVIKING_ACCOUNT — Tenant account (default: default) - OPENVIKING_USER — Tenant user (default: default) - OPENVIKING_AGENT — Tenant agent (default: hermes) + OPENVIKING_ACCOUNT — Optional tenant account override + OPENVIKING_USER — Optional tenant user override + OPENVIKING_AGENT — Tenant agent (default: hermes) Capabilities: - Automatic memory extraction on session commit (6 categories) @@ -29,11 +30,16 @@ import json import logging import mimetypes import os +import re +import shutil +import stat +import subprocess import tempfile import threading import time import uuid import zipfile +from dataclasses import dataclass, replace from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Set from urllib.parse import urlparse @@ -46,6 +52,20 @@ from tools.registry import tool_error logger = logging.getLogger(__name__) _DEFAULT_ENDPOINT = "http://127.0.0.1:1933" +_OPENVIKING_SERVICE_ENDPOINT = "https://api.vikingdb.cn-beijing.volces.com/openviking" +_DEFAULT_ACCOUNT = "" +_DEFAULT_USER = "" +_DEFAULT_AGENT = "hermes" +_OVCLI_CONFIG_ENV = "OPENVIKING_CLI_CONFIG_FILE" +_OVCLI_DEFAULT_RELATIVE_PATH = ".openviking/ovcli.conf" +_OVCLI_SAVED_PREFIX = "ovcli.conf." +_OPENVIKING_ENV_KEYS = ( + "OPENVIKING_ENDPOINT", + "OPENVIKING_API_KEY", + "OPENVIKING_ACCOUNT", + "OPENVIKING_USER", + "OPENVIKING_AGENT", +) _TIMEOUT = 30.0 _SESSION_DRAIN_TIMEOUT = 10.0 _DEFERRED_COMMIT_TIMEOUT = (_TIMEOUT * 2) + 5.0 @@ -69,6 +89,58 @@ _MEMORY_WRITE_TARGET_SUBDIR_MAP = { "user": "preferences", "memory": "patterns", } +_LOCAL_OPENVIKING_HOSTS = {"localhost", "127.0.0.1", "::1"} +_LOCAL_OPENVIKING_AUTOSTART_TIMEOUT = 60.0 +_OPENVIKING_SERVER_LOG_RELATIVE_PATH = Path("logs") / "openviking-server.log" +_OPENVIKING_RESPONDED_FAILURE_PREFIX = "OpenViking server responded" +_SETUP_CANCELLED = object() + + +@dataclass(frozen=True) +class _OvcliProfile: + source: str + name: str + path: Path + data: dict + values: dict + is_active: bool = False + + +class _OpenVikingHTTPError(RuntimeError): + def __init__(self, message: str, status_code: Optional[int] = None): + super().__init__(message) + self.status_code = status_code + + +def _sanitize_openviking_error_message(message: str, status_code: Optional[int] = None) -> str: + text = (message or "").strip() + status = f"HTTP {status_code}" if status_code else "HTTP error" + looks_like_html = bool(re.search(r"^\s*<(!doctype|html|head|body)\b", text, flags=re.IGNORECASE)) + if looks_like_html: + title_match = re.search(r"]*>(.*?)", text, flags=re.IGNORECASE | re.DOTALL) + if title_match: + title = re.sub(r"\s+", " ", title_match.group(1)).strip() + if "|" in title: + title = title.split("|", 1)[1].strip() + if status_code and title.startswith(f"{status_code}:"): + title = title.split(":", 1)[1].strip() + if title: + return f"{status}: {title}" + return f"{status}: OpenViking endpoint returned an HTML error page." + + if len(text) > 300: + return text[:297].rstrip() + "..." + return text or status + + +def _format_openviking_exception(error: Exception) -> str: + status_code = None + if isinstance(error, _OpenVikingHTTPError): + status_code = error.status_code + else: + response = getattr(error, "response", None) + status_code = getattr(response, "status_code", None) + return _sanitize_openviking_error_message(str(error), status_code) def _derive_openviking_user_text(content: Any) -> str: @@ -125,27 +197,26 @@ class _VikingClient: """Thin HTTP client for the OpenViking REST API.""" def __init__(self, endpoint: str, api_key: str = "", - account: str = "", user: str = "", agent: str = ""): + account: Optional[str] = None, user: Optional[str] = None, + agent: Optional[str] = None): self._endpoint = endpoint.rstrip("/") self._api_key = api_key + # Empty account/user fall back to "default" and the tenant headers are + # always sent — ROOT API keys require them (preserves the merged + # contract from #22414/#21232; an empty string must NOT omit the + # header). Use `or` (not `is not None`) so "" also falls back. self._account = account or os.environ.get("OPENVIKING_ACCOUNT", "default") self._user = user or os.environ.get("OPENVIKING_USER", "default") - self._agent = agent or os.environ.get("OPENVIKING_AGENT", "hermes") + self._agent = agent if agent is not None else os.environ.get("OPENVIKING_AGENT", _DEFAULT_AGENT) self._httpx = _get_httpx() if self._httpx is None: raise ImportError("httpx is required for OpenViking: pip install httpx") def _headers(self) -> dict: - # Always send tenant headers when account/user are configured. - # OpenViking 0.3.x requires X-OpenViking-Account and X-OpenViking-User - # for ROOT API key requests to tenant-scoped APIs — omitting them - # causes INVALID_ARGUMENT errors even when account="default". - # User-level keys can omit them (server derives tenancy from the key), - # but ROOT keys must always include them explicitly. - h = { - "Content-Type": "application/json", - "X-OpenViking-Agent": self._agent, - } + h = {"Content-Type": "application/json"} + if self._agent: + h["X-OpenViking-Actor-Peer"] = self._agent + h["X-OpenViking-Agent"] = self._agent if self._account: h["X-OpenViking-Account"] = self._account if self._user: @@ -170,15 +241,19 @@ class _VikingClient: data = None if resp.status_code >= 400: + message = _sanitize_openviking_error_message( + getattr(resp, "text", ""), + resp.status_code, + ) if isinstance(data, dict): error = data.get("error") if isinstance(error, dict): code = error.get("code", "HTTP_ERROR") - message = error.get("message", resp.text) - raise RuntimeError(f"{code}: {message}") + message = f"{code}: {error.get('message', message)}" + raise _OpenVikingHTTPError(message, resp.status_code) if data.get("status") == "error": - raise RuntimeError(str(data)) - resp.raise_for_status() + raise _OpenVikingHTTPError(str(data), resp.status_code) + raise _OpenVikingHTTPError(message or f"HTTP {resp.status_code}", resp.status_code) if isinstance(data, dict) and data.get("status") == "error": error = data.get("error") @@ -230,6 +305,20 @@ class _VikingClient: except Exception: return False + def health_payload(self) -> dict: + resp = self._httpx.get( + self._url("/health"), headers=self._headers(), timeout=3.0 + ) + return self._parse_response(resp) + + def validate_auth(self) -> dict: + """Validate authenticated OpenViking access without mutating state.""" + return self.get("/api/v1/system/status") + + def validate_root_access(self) -> dict: + """Validate ROOT access against a read-only admin endpoint.""" + return self.get("/api/v1/admin/accounts") + # --------------------------------------------------------------------------- # Tool schemas @@ -422,6 +511,1104 @@ def _path_from_file_uri(uri: str) -> Path | str: return Path(url2pathname(parsed.path)).expanduser() +def _clean_config_value(value: Any) -> str: + return value.strip() if isinstance(value, str) else "" + + +def _default_ovcli_config_path() -> Path: + return Path.home() / _OVCLI_DEFAULT_RELATIVE_PATH + + +def _resolve_ovcli_config_path(config_path: str = "") -> Path: + env_path = os.environ.get(_OVCLI_CONFIG_ENV, "").strip() + if env_path: + return Path(env_path).expanduser() + if config_path: + return Path(config_path).expanduser() + return _default_ovcli_config_path() + + +def _ovcli_config_dir() -> Path: + return _default_ovcli_config_path().parent + + +def _load_ovcli_config(path: Optional[Path] = None) -> dict: + config_path = path or _resolve_ovcli_config_path() + if not config_path.exists(): + return {} + with config_path.open(encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + raise ValueError(f"OpenViking CLI config must be a JSON object: {config_path}") + return data + + +def _connection_values_from_ovcli(data: dict) -> dict: + api_key = _clean_config_value(data.get("api_key")) or _clean_config_value(data.get("root_api_key")) + root_api_key = _clean_config_value(data.get("root_api_key")) + send_identity = not api_key or api_key == root_api_key + account = _clean_config_value(data.get("account") or data.get("account_id")) + user = _clean_config_value(data.get("user") or data.get("user_id")) + return { + "endpoint": _normalize_openviking_url(data.get("url")), + "api_key": api_key, + "root_api_key": root_api_key, + "account": account if send_identity else "", + "user": user if send_identity else "", + "agent": _clean_config_value(data.get("actor_peer_id") or data.get("agent_id")), + } + + +def _is_valid_ovcli_profile_name(name: str) -> bool: + if not name or name.strip() != name or name.startswith("."): + return False + if "/" in name or "\\" in name: + return False + return all(ch.isascii() and (ch.isalnum() or ch in {"-", "_"}) for ch in name) + + +def _validate_openviking_identity_value(value: str, *, field: str) -> tuple[bool, str, str]: + label = "Account ID" if field == "account" else "User ID" + identifier = "account_id" if field == "account" else "user_id" + trimmed = value.strip() + if not trimmed: + return False, f"{label} cannot be empty.", "" + if trimmed != value: + return False, f"{label} cannot start or end with whitespace.", "" + if field == "account" and trimmed.startswith("_"): + return False, "Account ID cannot start with '_'.", "" + if not all(ch.isascii() and (ch.isalnum() or ch in {"_", "-", ".", "@"}) for ch in trimmed): + return False, f"{label} can only contain letters, numbers, '_', '-', '.', and '@'.", "" + if trimmed.count("@") > 1: + return False, f"{identifier} must have at most one '@'.", "" + return True, "", trimmed + + +def _normalize_openviking_url(url: str) -> str: + trimmed = _clean_config_value(url).rstrip("/") + if not trimmed: + return _DEFAULT_ENDPOINT + lower = trimmed.lower() + if lower in {"::1", "[::1]"}: + return "http://[::1]:1933" + if lower.startswith("[::1]:"): + return f"http://[::1]:{trimmed.rsplit(':', 1)[1]}" + if lower.startswith("::1:"): + return f"http://[::1]:{trimmed.rsplit(':', 1)[1]}" + if "://" in trimmed: + return trimmed + host, _sep, port = trimmed.partition(":") + if host.lower() in {"localhost", "127.0.0.1"}: + return f"http://{host}:{port or '1933'}" + return trimmed + + +def _load_profile(path: Path, *, source: str, name: str) -> Optional[_OvcliProfile]: + try: + data = _load_ovcli_config(path) + except Exception as e: + logger.debug("Skipping invalid OpenViking CLI config %s: %s", path, e) + return None + return _OvcliProfile( + source=source, + name=name, + path=path, + data=data, + values=_connection_values_from_ovcli(data), + ) + + +def _profile_identity(path: Path) -> str: + try: + return str(path.expanduser().resolve()) + except OSError: + return str(path.expanduser()) + + +def _profiles_equivalent(left: _OvcliProfile, right: _OvcliProfile) -> bool: + return left.values == right.values + + +def _discover_ovcli_profiles() -> list[_OvcliProfile]: + profiles: list[_OvcliProfile] = [] + seen_paths: set[str] = set() + + def add(path: Path, *, source: str, name: str) -> None: + if not path.exists() or not path.is_file(): + return + identity = _profile_identity(path) + if identity in seen_paths: + return + profile = _load_profile(path, source=source, name=name) + if profile is None: + return + seen_paths.add(identity) + profiles.append(profile) + + env_path = os.environ.get(_OVCLI_CONFIG_ENV, "").strip() + if env_path: + add(Path(env_path).expanduser(), source="env", name=_OVCLI_CONFIG_ENV) + + active_path = _default_ovcli_config_path() + active_profile = _load_profile(active_path, source="active", name="active") if active_path.exists() else None + + config_dir = _ovcli_config_dir() + saved_start = len(profiles) + if config_dir.exists(): + for path in sorted(config_dir.iterdir(), key=lambda item: item.name): + if not path.is_file(): + continue + name = path.name.removeprefix(_OVCLI_SAVED_PREFIX) + if name == path.name or name == "bak" or not _is_valid_ovcli_profile_name(name): + continue + add(path, source="saved", name=name) + + if active_profile is not None: + marked_active = False + for idx in range(saved_start, len(profiles)): + if profiles[idx].source == "saved" and _profiles_equivalent(profiles[idx], active_profile): + profiles[idx] = replace(profiles[idx], is_active=True) + marked_active = True + break + has_env_profile = any(profile.source == "env" for profile in profiles) + has_saved_profile = any(profile.source == "saved" for profile in profiles) + active_identity = _profile_identity(active_profile.path) + if not marked_active and not has_env_profile and not has_saved_profile and active_identity not in seen_paths: + profiles.append(active_profile) + + return profiles + + +def _is_local_openviking_url(value: str) -> bool: + candidate = _normalize_openviking_url(value) + if not candidate: + return False + if "://" not in candidate: + candidate = f"//{candidate}" + parsed = urlparse(candidate) + scheme = (parsed.scheme or "http").lower() + return scheme == "http" and (parsed.hostname or "").lower() in _LOCAL_OPENVIKING_HOSTS + + +def _load_hermes_openviking_config() -> dict: + try: + from hermes_cli.config import load_config + + config = load_config() + memory_config = config.get("memory", {}) if isinstance(config, dict) else {} + provider_config = memory_config.get("openviking", {}) if isinstance(memory_config, dict) else {} + return dict(provider_config) if isinstance(provider_config, dict) else {} + except Exception: + return {} + + +def _env_value(name: str) -> Optional[str]: + return os.environ[name].strip() if name in os.environ else None + + +def _first_nonempty(*values: Optional[str], default: str = "") -> str: + for value in values: + if value: + return value + return default + + +def _resolve_connection_settings(provider_config: Optional[dict] = None) -> dict: + provider_config = dict(provider_config or {}) + ovcli_values: dict = {} + if provider_config.get("use_ovcli_config"): + ovcli_path = _resolve_ovcli_config_path(str(provider_config.get("ovcli_config_path") or "")) + ovcli_values = _connection_values_from_ovcli(_load_ovcli_config(ovcli_path)) + + endpoint_env = _env_value("OPENVIKING_ENDPOINT") + api_key_env = _env_value("OPENVIKING_API_KEY") + account_env = _env_value("OPENVIKING_ACCOUNT") + user_env = _env_value("OPENVIKING_USER") + agent_env = _env_value("OPENVIKING_AGENT") + + return { + "endpoint": _first_nonempty(endpoint_env, ovcli_values.get("endpoint"), default=_DEFAULT_ENDPOINT), + "api_key": api_key_env if api_key_env is not None else ovcli_values.get("api_key", ""), + "account": account_env if account_env is not None else ovcli_values.get("account", ""), + "user": user_env if user_env is not None else ovcli_values.get("user", ""), + "agent": _first_nonempty(agent_env, ovcli_values.get("agent"), default=_DEFAULT_AGENT), + } + + +def _env_writes_from_connection_values(values: dict) -> dict: + writes = {} + mapping = { + "OPENVIKING_ENDPOINT": "endpoint", + "OPENVIKING_API_KEY": "api_key", + "OPENVIKING_ACCOUNT": "account", + "OPENVIKING_USER": "user", + "OPENVIKING_AGENT": "agent", + } + for env_key, value_key in mapping.items(): + value = _clean_config_value(values.get(value_key)) + if value: + writes[env_key] = value + return writes + + +def _restrict_secret_file_permissions(path: Path) -> None: + try: + path.chmod(stat.S_IRUSR | stat.S_IWUSR) + except OSError as e: + logger.debug("Could not restrict permissions on %s: %s", path, e) + + +def _precreate_secret_file(path: Path) -> None: + """Create (or tighten) a secret-bearing file with 0600 BEFORE writing. + + Writing the file first and chmod-ing afterwards leaves a window where a + freshly-created file is world-readable under the default umask (e.g. 0644), + briefly exposing the api_key/root_api_key. Pre-creating with 0600 closes + that window; an existing file is tightened to 0600 here too. + """ + try: + if not path.exists(): + os.close(os.open(str(path), os.O_CREAT | os.O_WRONLY, 0o600)) + _restrict_secret_file_permissions(path) + except OSError as e: + logger.debug("Could not pre-create secret file %s: %s", path, e) + + +def _write_env_vars(env_path: Path, env_writes: dict, remove_keys: tuple[str, ...] = ()) -> None: + env_path.parent.mkdir(parents=True, exist_ok=True) + remove_set = set(remove_keys) - set(env_writes) + existing_lines = env_path.read_text(encoding="utf-8").splitlines() if env_path.exists() else [] + updated_keys = set() + new_lines = [] + for line in existing_lines: + key_match = line.split("=", 1)[0].strip() if "=" in line else "" + if key_match in remove_set: + continue + if key_match in env_writes: + new_lines.append(f"{key_match}={env_writes[key_match]}") + updated_keys.add(key_match) + else: + new_lines.append(line) + for key, val in env_writes.items(): + if key not in updated_keys: + new_lines.append(f"{key}={val}") + # Pre-create with 0600 so secrets are never briefly world-readable. + _precreate_secret_file(env_path) + env_path.write_text("\n".join(new_lines) + ("\n" if new_lines else ""), encoding="utf-8") + _restrict_secret_file_permissions(env_path) + + +def _remember_ovcli_path(provider_config: dict, ovcli_path: Path) -> None: + default_path = _default_ovcli_config_path().expanduser() + if os.environ.get(_OVCLI_CONFIG_ENV, "").strip() or ovcli_path.expanduser() != default_path: + provider_config["ovcli_config_path"] = str(ovcli_path) + else: + provider_config.pop("ovcli_config_path", None) + + +def _ovcli_data_from_connection_values(values: dict) -> dict: + data = {"url": _normalize_openviking_url(_clean_config_value(values.get("endpoint")) or _DEFAULT_ENDPOINT)} + api_key = _clean_config_value(values.get("api_key")) + root_api_key = _clean_config_value(values.get("root_api_key")) + account = _clean_config_value(values.get("account")) + user = _clean_config_value(values.get("user")) + agent = _clean_config_value(values.get("agent")) or _DEFAULT_AGENT + if api_key: + data["api_key"] = api_key + if root_api_key: + data["root_api_key"] = root_api_key + if account: + data["account"] = account + if user: + data["user"] = user + if agent: + data["actor_peer_id"] = agent + return data + + +def _write_ovcli_config(path: Path, values: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + # Pre-create with 0600 so secrets are never briefly world-readable. + _precreate_secret_file(path) + path.write_text(json.dumps(_ovcli_data_from_connection_values(values), indent=2) + "\n", encoding="utf-8") + _restrict_secret_file_permissions(path) + + +def _validate_openviking_reachability(endpoint: str) -> tuple[bool, str]: + endpoint = _normalize_openviking_url(endpoint) + try: + client = _VikingClient(endpoint) + if hasattr(client, "health_payload"): + payload = client.health_payload() + if payload.get("healthy") is False: + return False, "OpenViking server responded but reported unhealthy status." + if payload: + return True, "" + elif client.health(): + return True, "" + except Exception as e: + if _status_code_from_error(e) is not None: + return False, f"OpenViking server responded with {_format_openviking_exception(e)}." + return False, f"OpenViking server is not reachable at {endpoint}: {_format_openviking_exception(e)}" + return False, f"OpenViking server is not reachable at {endpoint}." + + +def _validate_openviking_auth(values: dict) -> tuple[bool, str]: + endpoint = _normalize_openviking_url(values.get("endpoint")) + try: + client = _VikingClient( + endpoint, + _clean_config_value(values.get("api_key")), + account=_clean_config_value(values.get("account")), + user=_clean_config_value(values.get("user")), + agent=_clean_config_value(values.get("agent")) or _DEFAULT_AGENT, + ) + client.validate_auth() + except Exception as e: + return False, f"OpenViking authentication validation failed: {_format_openviking_exception(e)}" + return True, "" + + +def _validate_openviking_root_access(values: dict) -> tuple[bool, str]: + endpoint = _normalize_openviking_url(values.get("endpoint")) + try: + client = _VikingClient( + endpoint, + _clean_config_value(values.get("api_key")), + agent=_clean_config_value(values.get("agent")) or _DEFAULT_AGENT, + ) + client.validate_root_access() + except Exception as e: + return False, f"OpenViking root API key validation failed: {_format_openviking_exception(e)}" + return True, "" + + +def _validate_openviking_user_key_scope(values: dict) -> tuple[bool, str]: + root_ok, _message = _validate_openviking_root_access(values) + if not root_ok: + return True, "" + return ( + False, + "That key has ROOT access. Choose Root API key and provide account/user, " + "or enter a user API key.", + ) + + +def _status_code_from_error(error: Exception) -> Optional[int]: + if isinstance(error, _OpenVikingHTTPError): + return error.status_code + response = getattr(error, "response", None) + return getattr(response, "status_code", None) + + +def _admin_probe_means_regular_key(error: Exception) -> bool: + return _status_code_from_error(error) in {401, 403, 404} + + +def _should_probe_openviking_auth(health: dict, *, require_api_key: bool, has_api_key: bool) -> bool: + if require_api_key or has_api_key: + return True + auth_mode = health.get("auth_mode") + if auth_mode == "dev": + return False + if auth_mode in {"api_key", "trusted", None}: + return True + return False + + +def _validate_openviking_setup_values( + values: dict, + *, + require_api_key: bool = False, +) -> tuple[bool, str, Optional[str]]: + endpoint = _normalize_openviking_url(values.get("endpoint")) + api_key = _clean_config_value(values.get("api_key")) + if require_api_key and not api_key: + return False, "Remote OpenViking configs require an API key.", None + + try: + client = _VikingClient( + endpoint, + api_key, + account=_clean_config_value(values.get("account")), + user=_clean_config_value(values.get("user")), + agent=_clean_config_value(values.get("agent")) or _DEFAULT_AGENT, + ) + health = client.health_payload() + if health.get("healthy") is False: + return False, "OpenViking server responded but reported unhealthy status.", None + if _should_probe_openviking_auth( + health, + require_api_key=require_api_key, + has_api_key=bool(api_key), + ): + client.validate_auth() + if not api_key: + return True, "", None + try: + client.validate_root_access() + return True, "", "root" + except Exception as e: + if _admin_probe_means_regular_key(e): + return True, "", "user" + raise + except Exception as e: + return False, f"OpenViking validation failed: {_format_openviking_exception(e)}", None + + +def _retry_or_cancel_manual_setup(select, title: str, message: str, cancelled): + print(f" {message}") + choice = select( + title, + [ + ("Retry", "try this step again"), + ("Cancel setup", "no changes saved"), + ], + default=0, + cancel_returns=cancelled, + ) + if choice == 0: + return True + return _SETUP_CANCELLED + + +def _print_validation_progress(message: str) -> None: + print(f" {message}", flush=True) + + +def _local_openviking_bind(endpoint: str) -> tuple[str, int]: + normalized = _normalize_openviking_url(endpoint) + parsed = urlparse(normalized) + host = parsed.hostname or "127.0.0.1" + port = parsed.port or 1933 + return host, port + + +def _openviking_server_log_path() -> Path: + try: + from hermes_constants import get_hermes_home + home = get_hermes_home() + except Exception: + home = Path(os.environ.get("HERMES_HOME", "")).expanduser() if os.environ.get("HERMES_HOME") else Path.home() / ".hermes" + return home / _OPENVIKING_SERVER_LOG_RELATIVE_PATH + + +def _start_local_openviking_server(endpoint: str) -> tuple[bool, str]: + server_cmd = shutil.which("openviking-server") + if not server_cmd: + return False, "openviking-server was not found on PATH. Start it manually, then retry." + try: + host, port = _local_openviking_bind(endpoint) + except ValueError as e: + return False, f"Could not parse local OpenViking URL: {e}" + log_path = _openviking_server_log_path() + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open("ab") as log_file: + subprocess.Popen( + [server_cmd, "--host", host, "--port", str(port)], + stdout=log_file, + stderr=log_file, + stdin=subprocess.DEVNULL, + start_new_session=True, + ) + except Exception as e: + return False, f"Could not start openviking-server: {e}" + return True, f"Started openviking-server on {host}:{port} in the background. Logs: {log_path}" + + +def _wait_for_openviking_health(endpoint: str, *, timeout_seconds: float = 15.0) -> bool: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + ok, _message = _validate_openviking_reachability(endpoint) + if ok: + return True + time.sleep(0.5) + return False + + +def _reachability_failure_allows_local_autostart(message: str) -> bool: + return not (message or "").startswith(_OPENVIKING_RESPONDED_FAILURE_PREFIX) + + +def _handle_unreachable_endpoint( + endpoint: str, + message: str, + select, + cancelled, + *, + allow_local_autostart: bool = True, +): + if _is_local_openviking_url(endpoint) and allow_local_autostart: + print(f" {message}") + choice = select( + " Local OpenViking server is down", + [ + ("Start local OpenViking", "run openviking-server and retry"), + ("Retry URL", "enter the server URL again"), + ("Cancel setup", "no changes saved"), + ], + default=0, + cancel_returns=cancelled, + ) + if choice == 0: + started, start_message = _start_local_openviking_server(endpoint) + print(f" {start_message}") + if not started: + return False + print(" Waiting for OpenViking server to become reachable...", flush=True) + if _wait_for_openviking_health( + endpoint, + timeout_seconds=_LOCAL_OPENVIKING_AUTOSTART_TIMEOUT, + ): + print(" OpenViking server is reachable.") + return True + print(" OpenViking server did not become reachable.") + return False + if choice == 1: + return False + return _SETUP_CANCELLED + + return _retry_or_cancel_manual_setup( + select, + " OpenViking server unhealthy" if _is_local_openviking_url(endpoint) else " OpenViking server unreachable", + message, + cancelled, + ) + + +def _emit_runtime_warning(message: str, warning_callback=None) -> None: + logger.warning("%s", message) + if warning_callback: + try: + warning_callback(message) + except Exception: + logger.debug("OpenViking runtime warning callback failed", exc_info=True) + + +def _emit_runtime_status(message: str, status_callback=None) -> None: + logger.info("%s", message) + if status_callback: + try: + status_callback(message) + except Exception: + logger.debug("OpenViking runtime status callback failed", exc_info=True) + + +def _runtime_openviking_timeout_message(endpoint: str) -> str: + return ( + f"Local OpenViking server at {endpoint} is not reachable. " + "Tried to start openviking-server, but it did not become reachable " + f"within {_LOCAL_OPENVIKING_AUTOSTART_TIMEOUT:.0f} seconds. " + "OpenViking memory disabled for this Hermes run." + ) + + +def _classify_runtime_openviking_health(client: _VikingClient, endpoint: str) -> tuple[str, str]: + """Classify runtime health without treating every false result as server absence.""" + try: + if hasattr(client, "health_payload"): + payload = client.health_payload() + if payload.get("healthy") is False: + return ( + "responded", + f"OpenViking server at {endpoint} responded but reported unhealthy status.", + ) + return "healthy", "" + if client.health(): + return "healthy", "" + except _OpenVikingHTTPError as e: + return ( + "responded", + f"OpenViking server at {endpoint} responded with {_format_openviking_exception(e)}.", + ) + except Exception: + return "unreachable", "" + return "unreachable", "" + + +def _prompt_profile_name(prompt, select, cancelled) -> str | object: + while True: + name = _clean_config_value(prompt("OpenViking profile name")) + if _is_valid_ovcli_profile_name(name): + return name + retry = _retry_or_cancel_manual_setup( + select, + " Invalid OpenViking profile name", + "Profile names can only contain letters, numbers, '-' and '_'.", + cancelled, + ) + if retry is _SETUP_CANCELLED: + return _SETUP_CANCELLED + + +def _confirm_replace_existing_profile(path: Path, values: dict, select, cancelled): + if not path.exists(): + return True + try: + existing_data = _load_ovcli_config(path) + except Exception: + existing_data = {} + if existing_data == _ovcli_data_from_connection_values(values): + return True + choice = select( + " OpenViking profile already exists", + [ + ("Choose another name", "leave the existing profile unchanged"), + ("Replace profile", "overwrite this saved OpenViking profile"), + ("Cancel setup", "no changes saved"), + ], + default=0, + cancel_returns=cancelled, + ) + if choice == 1: + return True + if choice == 0: + return False + return _SETUP_CANCELLED + + +def _prompt_manual_connection_values(prompt, select, cancelled, *, service: bool = False): + if service: + endpoint = _OPENVIKING_SERVICE_ENDPOINT + print(f" OpenViking Service endpoint: {endpoint}") + else: + while True: + endpoint = _normalize_openviking_url(prompt("OpenViking server URL", default=_DEFAULT_ENDPOINT)) + _print_validation_progress("Checking OpenViking server...") + reachable, message = _validate_openviking_reachability(endpoint) + if reachable: + print(" OpenViking server is reachable.") + break + retry = _handle_unreachable_endpoint( + endpoint, + message, + select, + cancelled, + allow_local_autostart=_reachability_failure_allows_local_autostart(message), + ) + if retry is True: + break + if retry is _SETUP_CANCELLED: + return _SETUP_CANCELLED + + is_local = _is_local_openviking_url(endpoint) + api_key_type = "user" if service else "" + prefilled_api_key = "" + prefilled_agent = "" + while True: + values = { + "endpoint": endpoint, + "api_key": "", + "root_api_key": "", + "account": "", + "user": "", + "agent": "", + } + if not api_key_type and is_local: + credential_choice = select( + " OpenViking credential", + [ + ("No API key", "local dev mode"), + ("User API key", "server derives account/user automatically"), + ("Root API key", "requires account and user IDs"), + ], + default=0, + cancel_returns=cancelled, + ) + if credential_choice == cancelled: + return _SETUP_CANCELLED + if credential_choice == 0: + values["agent"] = _clean_config_value( + prompt("OpenViking agent", default=_DEFAULT_AGENT) + ) or _DEFAULT_AGENT + _print_validation_progress("Validating OpenViking local dev access...") + valid, message, _role = _validate_openviking_setup_values(values) + if valid: + print(" OpenViking local dev access validated.") + return values + retry = _retry_or_cancel_manual_setup( + select, + " OpenViking credential failed", + message, + cancelled, + ) + if retry is _SETUP_CANCELLED: + return _SETUP_CANCELLED + continue + api_key_type = "root" if credential_choice == 2 else "user" + elif not api_key_type: + credential_choice = select( + " OpenViking API key type", + [ + ("User API key", "server derives account/user automatically"), + ("Root API key", "requires account and user IDs"), + ], + default=0, + cancel_returns=cancelled, + ) + if credential_choice == cancelled: + return _SETUP_CANCELLED + api_key_type = "root" if credential_choice == 1 else "user" + + values["api_key_type"] = api_key_type + if service: + api_key_label = "OpenViking API key" + else: + api_key_label = ( + "OpenViking root API key" + if api_key_type == "root" + else "OpenViking user API key" + ) + if prefilled_api_key: + values["api_key"] = prefilled_api_key + prefilled_api_key = "" + else: + values["api_key"] = _clean_config_value(prompt(api_key_label, secret=True)) + if not values["api_key"]: + retry = _retry_or_cancel_manual_setup( + select, + " OpenViking API key required", + f"{api_key_label} is required.", + cancelled, + ) + if retry is _SETUP_CANCELLED: + return _SETUP_CANCELLED + continue + + if api_key_type == "root": + _print_validation_progress("Validating OpenViking root API key...") + valid, message, role = _validate_openviking_setup_values(values, require_api_key=True) + root_ok = valid and role == "root" + if not root_ok: + if valid and role == "user": + print(" That key is valid, but it is a user API key.") + route_choice = select( + " OpenViking key is a user key", + [ + ("Use as User API key", "server derives account/user automatically"), + ("Re-enter Root API key", "try another root key"), + ("Cancel setup", "no changes saved"), + ], + default=0, + cancel_returns=cancelled, + ) + if route_choice == 0: + prefilled_api_key = values["api_key"] + api_key_type = "user" + continue + if route_choice == 1: + api_key_type = "root" + continue + return _SETUP_CANCELLED + retry = _retry_or_cancel_manual_setup( + select, + " OpenViking root API key failed", + message, + cancelled, + ) + if retry is _SETUP_CANCELLED: + return _SETUP_CANCELLED + continue + print(" OpenViking root API key validated.") + values["root_api_key"] = values["api_key"] + account_ok, account_message, account = _validate_openviking_identity_value( + prompt("OpenViking account"), + field="account", + ) + user_ok, user_message, user = _validate_openviking_identity_value( + prompt("OpenViking user"), + field="user", + ) + values["account"] = account + values["user"] = user + if not account_ok or not user_ok: + message = account_message if not account_ok else user_message + retry = _retry_or_cancel_manual_setup( + select, + " OpenViking tenant identity required", + message, + cancelled, + ) + if retry is _SETUP_CANCELLED: + return _SETUP_CANCELLED + prefilled_api_key = values["api_key"] + continue + + if prefilled_agent: + values["agent"] = prefilled_agent + prefilled_agent = "" + else: + values["agent"] = _clean_config_value( + prompt("OpenViking agent", default=_DEFAULT_AGENT) + ) or _DEFAULT_AGENT + _print_validation_progress("Validating OpenViking API access...") + valid, message, role = _validate_openviking_setup_values( + values, + require_api_key=service or not is_local, + ) + if valid: + if api_key_type == "user": + if role == "root": + print(" That key is valid, but it has root access.") + route_choice = select( + " OpenViking user API key is root key", + [ + ("Configure as Root API key", "provide account and user IDs"), + ("Re-enter User API key", "try another user key"), + ("Cancel setup", "no changes saved"), + ], + default=0, + cancel_returns=cancelled, + ) + if route_choice == 0: + prefilled_api_key = values["api_key"] + prefilled_agent = values["agent"] + api_key_type = "root" + continue + if route_choice == 1: + api_key_type = "user" + continue + return _SETUP_CANCELLED + if api_key_type == "root" and role != "root": + retry = _retry_or_cancel_manual_setup( + select, + " OpenViking root API key failed", + "The supplied key was not accepted as a root API key.", + cancelled, + ) + if retry is _SETUP_CANCELLED: + return _SETUP_CANCELLED + continue + print(" OpenViking API access validated.") + return values + retry = _retry_or_cancel_manual_setup( + select, + " OpenViking API access failed", + message, + cancelled, + ) + if retry is _SETUP_CANCELLED: + return _SETUP_CANCELLED + + +def _set_openviking_provider(config: dict, provider_config: dict) -> None: + config["memory"]["provider"] = "openviking" + config["memory"]["openviking"] = provider_config + + +def _link_ovcli_profile( + *, + config: dict, + provider_config: dict, + env_path: Path, + ovcli_path: Path, +) -> None: + for key in ("endpoint", "api_key", "root_api_key", "account", "user", "agent", "api_key_type"): + provider_config.pop(key, None) + provider_config["use_ovcli_config"] = True + _remember_ovcli_path(provider_config, ovcli_path) + _set_openviking_provider(config, provider_config) + _write_env_vars(env_path, {}, remove_keys=_OPENVIKING_ENV_KEYS) + for key in _OPENVIKING_ENV_KEYS: + os.environ.pop(key, None) + + +def _save_hermes_only_config( + *, + config: dict, + provider_config: dict, + env_path: Path, + values: dict, +) -> None: + provider_config["use_ovcli_config"] = False + provider_config.pop("ovcli_config_path", None) + _set_openviking_provider(config, provider_config) + _write_env_vars( + env_path, + _env_writes_from_connection_values(values), + remove_keys=_OPENVIKING_ENV_KEYS, + ) + + +def _profile_display_name(profile: _OvcliProfile) -> str: + if profile.source == "env": + return _OVCLI_CONFIG_ENV + if profile.source == "active": + return "ovcli.conf" + return profile.name + + +def _profile_description(profile: _OvcliProfile) -> str: + endpoint = _clean_config_value(profile.values.get("endpoint")) or _DEFAULT_ENDPOINT + return f"{endpoint} ({profile.path})" + + +def _validate_profile_for_setup(profile: _OvcliProfile) -> tuple[bool, str, Optional[str]]: + require_api_key = not _is_local_openviking_url(profile.values.get("endpoint", "")) + return _validate_openviking_setup_values(profile.values, require_api_key=require_api_key) + + +def _print_openviking_ready(message: str, path: Optional[Path] = None) -> None: + print("\n OpenViking memory is ready") + print(f" {message}") + if path is not None: + print(f" Config file: {path}") + print(" Start a new Hermes session to activate.\n") + + +def _run_existing_profile_setup( + *, + profiles: list[_OvcliProfile], + select, + cancelled, + config: dict, + provider_config: dict, + env_path: Path, +) -> bool | object: + while True: + choice = select( + " OpenViking profile", + [(_profile_display_name(profile), _profile_description(profile)) for profile in profiles], + default=0, + cancel_returns=cancelled, + ) + if choice == cancelled: + return _SETUP_CANCELLED + if choice < 0 or choice >= len(profiles): + return _SETUP_CANCELLED + + profile = profiles[choice] + _print_validation_progress("Validating OpenViking profile...") + ok, message, _role = _validate_profile_for_setup(profile) + if ok: + _link_ovcli_profile( + config=config, + provider_config=provider_config, + env_path=env_path, + ovcli_path=profile.path, + ) + _print_openviking_ready(f"Linked profile: {_profile_display_name(profile)}", profile.path) + return True + + print(f" {message}") + retry = select( + " OpenViking profile validation failed", + [ + ("Choose another profile", "select a different OpenViking profile"), + ("Retry validation", "try this profile again"), + ("Cancel setup", "no changes saved"), + ], + default=0, + cancel_returns=cancelled, + ) + if retry == 0: + continue + if retry == 1: + _print_validation_progress("Validating OpenViking profile...") + ok, message, _role = _validate_profile_for_setup(profile) + if ok: + _link_ovcli_profile( + config=config, + provider_config=provider_config, + env_path=env_path, + ovcli_path=profile.path, + ) + _print_openviking_ready(f"Linked profile: {_profile_display_name(profile)}", profile.path) + return True + print(f" {message}") + continue + return _SETUP_CANCELLED + + +def _mirror_manual_config_to_openviking_store( + *, + prompt, + select, + cancelled, + values: dict, +) -> Path | object: + while True: + name = _prompt_profile_name(prompt, select, cancelled) + if name is _SETUP_CANCELLED: + return _SETUP_CANCELLED + path = _ovcli_config_dir() / f"{_OVCLI_SAVED_PREFIX}{name}" + replace = _confirm_replace_existing_profile(path, values, select, cancelled) + if replace is _SETUP_CANCELLED: + return _SETUP_CANCELLED + if replace is False: + continue + _write_ovcli_config(path, values) + return path + + +def _run_create_profile_setup( + *, + prompt, + select, + cancelled, + config: dict, + provider_config: dict, + env_path: Path, +) -> bool | object: + source_choice = select( + " OpenViking connection", + [ + ("OpenViking Service (VolcEngine Cloud)", "use the managed OpenViking endpoint"), + ("Custom", "use a local, VPS, or self-hosted OpenViking server"), + ], + default=0, + cancel_returns=cancelled, + ) + if source_choice == cancelled: + return _SETUP_CANCELLED + + values = _prompt_manual_connection_values(prompt, select, cancelled, service=(source_choice == 0)) + if values is _SETUP_CANCELLED: + return _SETUP_CANCELLED + if values is None: + return False + + save_choice = select( + " Save OpenViking config", + [ + ("Keep in Hermes only", "write values only to Hermes .env"), + ("Mirror to OpenViking store", "write ~/.openviking/ovcli.conf. and link it"), + ], + default=1, + cancel_returns=cancelled, + ) + if save_choice == cancelled: + return _SETUP_CANCELLED + + if save_choice == 1: + ovcli_path = _mirror_manual_config_to_openviking_store( + prompt=prompt, + select=select, + cancelled=cancelled, + values=values, + ) + if ovcli_path is _SETUP_CANCELLED: + return _SETUP_CANCELLED + _link_ovcli_profile( + config=config, + provider_config=provider_config, + env_path=env_path, + ovcli_path=ovcli_path, + ) + _print_openviking_ready("Created and linked OpenViking profile.", ovcli_path) + return True + + _save_hermes_only_config( + config=config, + provider_config=provider_config, + env_path=env_path, + values=values, + ) + _print_openviking_ready("Connection saved to Hermes .env.") + return True + + # --------------------------------------------------------------------------- # MemoryProvider implementation # --------------------------------------------------------------------------- @@ -455,6 +1642,8 @@ class OpenVikingMemoryProvider(MemoryProvider): self._prefetch_result = "" self._prefetch_lock = threading.Lock() self._prefetch_thread: Optional[threading.Thread] = None + self._runtime_start_lock = threading.Lock() + self._runtime_start_thread: Optional[threading.Thread] = None # All prefetch threads ever spawned (daemon, short-lived). Tracked so # shutdown() can drain them and rapid re-queues don't orphan a still- # running thread by overwriting the single _prefetch_thread slot. @@ -471,7 +1660,16 @@ class OpenVikingMemoryProvider(MemoryProvider): def is_available(self) -> bool: """Check if OpenViking endpoint is configured. No network calls.""" - return bool(os.environ.get("OPENVIKING_ENDPOINT")) + if os.environ.get("OPENVIKING_ENDPOINT"): + return True + provider_config = _load_hermes_openviking_config() + if not provider_config.get("use_ovcli_config"): + return False + try: + ovcli_path = _resolve_ovcli_config_path(str(provider_config.get("ovcli_config_path") or "")) + return bool(_connection_values_from_ovcli(_load_ovcli_config(ovcli_path)).get("endpoint")) + except Exception: + return False def get_config_schema(self): return [ @@ -490,14 +1688,12 @@ class OpenVikingMemoryProvider(MemoryProvider): }, { "key": "account", - "description": "OpenViking tenant account ID ([default], used when local mode, OPENVIKING_API_KEY is empty)", - "default": "default", + "description": "OpenViking tenant account ID (blank for user API keys)", "env_var": "OPENVIKING_ACCOUNT", }, { "key": "user", - "description": "OpenViking user ID within the account ([default], used when local mode, OPENVIKING_API_KEY is empty)", - "default": "default", + "description": "OpenViking user ID within the account (blank for user API keys)", "env_var": "OPENVIKING_USER", }, { @@ -508,22 +1704,246 @@ class OpenVikingMemoryProvider(MemoryProvider): }, ] + def get_status_config(self, provider_config: dict) -> dict: + provider_config = dict(provider_config or {}) + if provider_config.get("use_ovcli_config"): + ovcli_path = _resolve_ovcli_config_path(str(provider_config.get("ovcli_config_path") or "")) + try: + settings = _resolve_connection_settings(provider_config) + except Exception as e: + return { + "use_ovcli_config": True, + "ovcli_config_path": str(ovcli_path), + "error": _format_openviking_exception(e), + } + + display = { + "use_ovcli_config": True, + "ovcli_config_path": str(ovcli_path), + "endpoint": settings.get("endpoint") or _DEFAULT_ENDPOINT, + "agent": settings.get("agent") or _DEFAULT_AGENT, + } + if settings.get("account"): + display["account"] = settings["account"] + if settings.get("user"): + display["user"] = settings["user"] + env_overrides = [key for key in _OPENVIKING_ENV_KEYS if _env_value(key) is not None] + if env_overrides: + display["env_overrides"] = ", ".join(env_overrides) + return display + + display = dict(provider_config) + for key in ("api_key", "root_api_key"): + if key in display: + display[key] = "(set)" + return display + + def post_setup(self, hermes_home: str, config: dict) -> None: + """Custom setup that can reuse OpenViking's shared CLI config.""" + from hermes_cli.config import save_config + from hermes_cli.memory_setup import _CANCELLED, _curses_select, _print_cancelled_setup, _prompt + + hermes_home_path = Path(hermes_home) + env_path = hermes_home_path / ".env" + if not isinstance(config.get("memory"), dict): + config["memory"] = {} + provider_config = config["memory"].get("openviking", {}) + if not isinstance(provider_config, dict): + provider_config = {} + + print("\n OpenViking memory setup\n") + + profiles = _discover_ovcli_profiles() + if profiles: + setup_options = [ + ("Use existing OpenViking profile", "choose from detected ovcli.conf profiles"), + ("Create new OpenViking profile", "enter a new URL/API key"), + ] + choice = _curses_select( + " OpenViking config source", + setup_options, + default=0, + cancel_returns=_CANCELLED, + ) + if choice == _CANCELLED: + _print_cancelled_setup() + return + + if choice == 0: + result = _run_existing_profile_setup( + profiles=profiles, + select=_curses_select, + cancelled=_CANCELLED, + config=config, + provider_config=provider_config, + env_path=env_path, + ) + if result is _SETUP_CANCELLED: + _print_cancelled_setup() + return + if result: + save_config(config) + return + + else: + print(" No existing OpenViking CLI profiles found. Creating a new config.") + + result = _run_create_profile_setup( + prompt=_prompt, + select=_curses_select, + cancelled=_CANCELLED, + config=config, + provider_config=provider_config, + env_path=env_path, + ) + if result is _SETUP_CANCELLED: + _print_cancelled_setup() + return + if result: + save_config(config) + + def _start_runtime_openviking_waiter( + self, + *, + status_callback=None, + warning_callback=None, + ) -> None: + with self._runtime_start_lock: + if self._runtime_start_thread and self._runtime_start_thread.is_alive(): + return + self._runtime_start_thread = threading.Thread( + target=self._finish_runtime_openviking_start, + kwargs={ + "status_callback": status_callback, + "warning_callback": warning_callback, + }, + daemon=True, + name="openviking-runtime-start", + ) + self._runtime_start_thread.start() + + def _finish_runtime_openviking_start( + self, + *, + status_callback=None, + warning_callback=None, + ) -> None: + endpoint = self._endpoint + if not _wait_for_openviking_health( + endpoint, + timeout_seconds=_LOCAL_OPENVIKING_AUTOSTART_TIMEOUT, + ): + _emit_runtime_warning( + _runtime_openviking_timeout_message(endpoint), + warning_callback, + ) + return + + try: + client = _VikingClient( + endpoint, + self._api_key, + account=self._account, + user=self._user, + agent=self._agent, + ) + if not client.health(): + _emit_runtime_warning( + f"OpenViking server at {endpoint} is still not reachable after auto-start; " + "OpenViking memory disabled for this Hermes run.", + warning_callback, + ) + return + except ImportError: + logger.warning("httpx not installed — OpenViking plugin disabled") + return + except Exception as e: + _emit_runtime_warning( + f"OpenViking server at {endpoint} could not be attached after auto-start: {e}. " + "OpenViking memory disabled for this Hermes run.", + warning_callback, + ) + return + + self._client = client + _emit_runtime_status( + f"Local OpenViking server at {endpoint} is reachable; OpenViking memory is active for later turns.", + status_callback, + ) + + def _handle_runtime_openviking_unreachable( + self, + *, + status_callback=None, + warning_callback=None, + ) -> None: + endpoint = self._endpoint + if not _is_local_openviking_url(endpoint): + _emit_runtime_warning( + f"Remote OpenViking server at {endpoint} is not reachable; " + "OpenViking memory disabled for this Hermes run. " + "Check the configured endpoint and network connectivity.", + warning_callback, + ) + self._client = None + return + + started, start_message = _start_local_openviking_server(endpoint) + if not started: + _emit_runtime_warning( + f"Local OpenViking server at {endpoint} is not reachable. {start_message} " + "OpenViking memory disabled for this Hermes run.", + warning_callback, + ) + self._client = None + return + + self._client = None + _emit_runtime_status( + f"{start_message} OpenViking memory is starting in the background and will attach when ready.", + status_callback, + ) + self._start_runtime_openviking_waiter( + status_callback=status_callback, + warning_callback=warning_callback, + ) + def initialize(self, session_id: str, **kwargs) -> None: - self._endpoint = os.environ.get("OPENVIKING_ENDPOINT", _DEFAULT_ENDPOINT) - self._api_key = os.environ.get("OPENVIKING_API_KEY", "") - self._account = os.environ.get("OPENVIKING_ACCOUNT", "default") - self._user = os.environ.get("OPENVIKING_USER", "default") - self._agent = os.environ.get("OPENVIKING_AGENT", "hermes") + settings = _resolve_connection_settings(_load_hermes_openviking_config()) + self._endpoint = settings["endpoint"] + self._api_key = settings["api_key"] + self._account = settings["account"] + self._user = settings["user"] + self._agent = settings["agent"] self._session_id = session_id self._turn_count = 0 + warning_callback = ( + kwargs.get("warning_callback") + if kwargs.get("platform") == "cli" + else None + ) + status_callback = ( + kwargs.get("status_callback") + if kwargs.get("platform") == "cli" + else None + ) try: self._client = _VikingClient( self._endpoint, self._api_key, account=self._account, user=self._user, agent=self._agent, ) - if not self._client.health(): - logger.warning("OpenViking server at %s is not reachable", self._endpoint) + health_state, health_message = _classify_runtime_openviking_health(self._client, self._endpoint) + if health_state == "unreachable": + self._handle_runtime_openviking_unreachable( + status_callback=status_callback, + warning_callback=warning_callback, + ) + elif health_state != "healthy": + _emit_runtime_warning( + f"{health_message} OpenViking memory disabled for this Hermes run.", + warning_callback, + ) self._client = None except ImportError: logger.warning("httpx not installed — OpenViking plugin disabled") diff --git a/plugins/memory/openviking/plugin.yaml b/plugins/memory/openviking/plugin.yaml index 714877f9763..18b8ea78741 100644 --- a/plugins/memory/openviking/plugin.yaml +++ b/plugins/memory/openviking/plugin.yaml @@ -3,7 +3,6 @@ version: 2.0.0 description: "OpenViking context database — session-managed memory with automatic extraction, tiered retrieval, and filesystem-style knowledge browsing." pip_dependencies: - httpx -requires_env: - - OPENVIKING_ENDPOINT +requires_env: [] hooks: - on_session_end diff --git a/tests/hermes_cli/test_memory_setup.py b/tests/hermes_cli/test_memory_setup.py new file mode 100644 index 00000000000..b5b574230c7 --- /dev/null +++ b/tests/hermes_cli/test_memory_setup.py @@ -0,0 +1,198 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import hermes_cli.memory_setup as memory_setup +from hermes_cli.memory_setup import _CANCELLED, _curses_select + + +def test_curses_select_cancel_defaults_to_selected(monkeypatch): + captured = {} + + def fake_radiolist(title, items, selected=0, *, cancel_returns=None): + captured.update({ + "title": title, + "items": items, + "selected": selected, + "cancel_returns": cancel_returns, + }) + return cancel_returns + + monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", fake_radiolist) + + result = _curses_select("Pick one", [("first", "desc"), ("second", "")], default=1) + + assert result == 1 + assert captured == { + "title": "Pick one", + "items": ["first - desc", "second"], + "selected": 1, + "cancel_returns": 1, + } + + +def test_curses_select_accepts_explicit_cancel_value(monkeypatch): + captured = {} + + def fake_radiolist(title, items, selected=0, *, cancel_returns=None): + captured["cancel_returns"] = cancel_returns + return cancel_returns + + monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", fake_radiolist) + + result = _curses_select("Pick one", [("first", "")], default=0, cancel_returns=_CANCELLED) + + assert result == _CANCELLED + assert captured["cancel_returns"] == _CANCELLED + + +def test_curses_select_clears_after_picker_returns(monkeypatch): + events = [] + + def fake_radiolist(title, items, selected=0, *, cancel_returns=None): + events.append("picker") + return selected + + monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", fake_radiolist) + monkeypatch.setattr(memory_setup, "_clear_interactive_transition", lambda: events.append("clear")) + + result = _curses_select("Pick one", [("first", "")], default=0) + + assert result == 0 + assert events == ["picker", "clear"] + + +def test_cmd_setup_top_level_cancel_writes_nothing(monkeypatch): + save_config = MagicMock() + load_config = MagicMock(side_effect=AssertionError("cancel should not load config")) + + monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: [("fake", "local", object())]) + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: kwargs["cancel_returns"]) + monkeypatch.setattr("hermes_cli.config.load_config", load_config) + monkeypatch.setattr("hermes_cli.config.save_config", save_config) + + memory_setup.cmd_setup(SimpleNamespace()) + + load_config.assert_not_called() + save_config.assert_not_called() + + +def test_cmd_setup_builtin_selection_still_saves_builtin(monkeypatch): + save_config = MagicMock() + config = {"memory": {"provider": "openviking"}} + providers = [("fake", "local", object())] + + monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: providers) + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: len(providers)) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: config) + monkeypatch.setattr("hermes_cli.config.save_config", save_config) + + memory_setup.cmd_setup(SimpleNamespace()) + + assert config["memory"]["provider"] == "" + save_config.assert_called_once_with(config) + + +def test_cmd_setup_clears_interactive_picker_before_provider_post_setup(monkeypatch): + events = [] + + class PostSetupProvider: + def post_setup(self, hermes_home, config): + events.append("post_setup") + + monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: [("openviking", "local", PostSetupProvider())]) + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: events.append("select") or 0) + monkeypatch.setattr(memory_setup, "_clear_interactive_transition", lambda: events.append("clear"), raising=False) + monkeypatch.setattr(memory_setup, "_install_dependencies", lambda name: events.append("install")) + monkeypatch.setattr(memory_setup, "get_hermes_home", lambda: "/tmp/hermes-test") + monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"memory": {}}) + + memory_setup.cmd_setup(SimpleNamespace()) + + assert events == ["select", "clear", "install", "post_setup"] + + +def test_cmd_setup_provider_clears_before_provider_post_setup(monkeypatch): + events = [] + + class PostSetupProvider: + def post_setup(self, hermes_home, config): + events.append("post_setup") + + monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: [("openviking", "local", PostSetupProvider())]) + monkeypatch.setattr(memory_setup, "_clear_interactive_transition", lambda: events.append("clear"), raising=False) + monkeypatch.setattr(memory_setup, "_install_dependencies", lambda name: events.append("install")) + monkeypatch.setattr(memory_setup, "get_hermes_home", lambda: "/tmp/hermes-test") + monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"memory": {}}) + + memory_setup.cmd_setup_provider("openviking") + + assert events == ["clear", "install", "post_setup"] + + +def test_cmd_status_prefers_provider_status_config(monkeypatch, capsys): + class StatusProvider: + def get_status_config(self, provider_config): + assert provider_config["endpoint"] == "http://stale.local" + return { + "use_ovcli_config": True, + "ovcli_config_path": "/tmp/ovcli.conf.VPS_ROOT", + "endpoint": "https://vps.example", + "account": "acct", + "user": "alice", + "agent": "hermes", + } + + def is_available(self): + return True + + config = { + "memory": { + "provider": "openviking", + "openviking": { + "use_ovcli_config": True, + "ovcli_config_path": "/tmp/ovcli.conf.VPS_ROOT", + "endpoint": "http://stale.local", + }, + } + } + monkeypatch.setattr("hermes_cli.config.load_config", lambda: config) + monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: [("openviking", "API key / local", StatusProvider())]) + + memory_setup.cmd_status(SimpleNamespace()) + + output = capsys.readouterr().out + assert "endpoint: https://vps.example" in output + assert "http://stale.local" not in output + + +def test_cmd_setup_generic_choice_cancel_writes_nothing(tmp_path, monkeypatch): + class ChoiceProvider: + def __init__(self): + self.save_config = MagicMock() + + def get_config_schema(self): + return [{ + "key": "mode", + "description": "Mode", + "default": "one", + "choices": ["one", "two"], + }] + + provider = ChoiceProvider() + selections = iter([0, _CANCELLED]) + save_config = MagicMock() + install_dependencies = MagicMock() + + monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: [("fake", "local", provider)]) + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(selections)) + monkeypatch.setattr(memory_setup, "_install_dependencies", install_dependencies) + monkeypatch.setattr(memory_setup, "get_hermes_home", lambda: tmp_path) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"memory": {}}) + monkeypatch.setattr("hermes_cli.config.save_config", save_config) + + memory_setup.cmd_setup(SimpleNamespace()) + + install_dependencies.assert_called_once_with("fake") + save_config.assert_not_called() + provider.save_config.assert_not_called() + assert not (tmp_path / ".env").exists() diff --git a/tests/hermes_cli/test_secret_prompt.py b/tests/hermes_cli/test_secret_prompt.py index 50aec43cd88..d33bb07ea4d 100644 --- a/tests/hermes_cli/test_secret_prompt.py +++ b/tests/hermes_cli/test_secret_prompt.py @@ -25,7 +25,7 @@ def test_collect_masked_input_shows_feedback_without_echoing_secret(): value, output = _run_collect("secret\n") assert value == "secret" - assert output == "API key: ******\n" + assert output == "API key: ******\r\n" assert "secret" not in output @@ -33,7 +33,7 @@ def test_collect_masked_input_handles_backspace(): value, output = _run_collect("sec\x7fret\r") assert value == "seret" - assert output == "API key: ***\b \b***\n" + assert output == "API key: ***\b \b***\r\n" assert "secret" not in output @@ -47,7 +47,7 @@ def test_collect_masked_input_raises_keyboard_interrupt(): "API key: ", ) - assert "".join(output) == "API key: \n" + assert "".join(output) == "API key: \r\n" def test_masked_secret_prompt_falls_back_to_getpass_for_non_tty(monkeypatch): diff --git a/tests/plugins/memory/test_hindsight_provider.py b/tests/plugins/memory/test_hindsight_provider.py index b121a2bb20b..bbcb151baa9 100644 --- a/tests/plugins/memory/test_hindsight_provider.py +++ b/tests/plugins/memory/test_hindsight_provider.py @@ -15,6 +15,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from hermes_cli.memory_setup import _CANCELLED from plugins.memory.hindsight import ( HindsightMemoryProvider, RECALL_SCHEMA, @@ -376,6 +377,61 @@ class TestConfig: class TestPostSetup: + def test_setup_cancel_at_mode_picker_writes_nothing(self, tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes-home" + user_home = tmp_path / "user-home" + user_home.mkdir() + monkeypatch.setenv("HOME", str(user_home)) + monkeypatch.setattr("plugins.memory.hindsight.get_hermes_home", lambda: hermes_home) + + save_config = MagicMock() + which = MagicMock(return_value="/usr/bin/uv") + run = MagicMock() + monkeypatch.setattr("hermes_cli.memory_setup._curses_select", lambda *args, **kwargs: _CANCELLED) + monkeypatch.setattr("shutil.which", which) + monkeypatch.setattr("subprocess.run", run) + monkeypatch.setattr("builtins.input", MagicMock(side_effect=AssertionError("prompt should not run"))) + monkeypatch.setattr("getpass.getpass", MagicMock(side_effect=AssertionError("prompt should not run"))) + monkeypatch.setattr("hermes_cli.config.save_config", save_config) + + provider = HindsightMemoryProvider() + provider.post_setup(str(hermes_home), {"memory": {"provider": "builtin"}}) + + save_config.assert_not_called() + which.assert_not_called() + run.assert_not_called() + assert not (hermes_home / ".env").exists() + assert not (hermes_home / "hindsight" / "config.json").exists() + assert not (user_home / ".hindsight" / "profiles" / "hermes.env").exists() + + def test_local_embedded_setup_cancel_at_llm_picker_writes_nothing(self, tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes-home" + user_home = tmp_path / "user-home" + user_home.mkdir() + monkeypatch.setenv("HOME", str(user_home)) + monkeypatch.setattr("plugins.memory.hindsight.get_hermes_home", lambda: hermes_home) + + selections = iter([1, _CANCELLED]) # local_embedded, then cancel LLM picker + save_config = MagicMock() + which = MagicMock(return_value="/usr/bin/uv") + run = MagicMock() + monkeypatch.setattr("hermes_cli.memory_setup._curses_select", lambda *args, **kwargs: next(selections)) + monkeypatch.setattr("shutil.which", which) + monkeypatch.setattr("subprocess.run", run) + monkeypatch.setattr("builtins.input", MagicMock(side_effect=AssertionError("prompt should not run"))) + monkeypatch.setattr("getpass.getpass", MagicMock(side_effect=AssertionError("prompt should not run"))) + monkeypatch.setattr("hermes_cli.config.save_config", save_config) + + provider = HindsightMemoryProvider() + provider.post_setup(str(hermes_home), {"memory": {"provider": "builtin"}}) + + save_config.assert_not_called() + which.assert_not_called() + run.assert_not_called() + assert not (hermes_home / ".env").exists() + assert not (hermes_home / "hindsight" / "config.json").exists() + assert not (user_home / ".hindsight" / "profiles" / "hermes.env").exists() + def test_local_embedded_setup_materializes_profile_env(self, tmp_path, monkeypatch): hermes_home = tmp_path / "hermes-home" user_home = tmp_path / "user-home" diff --git a/tests/plugins/memory/test_openviking_provider.py b/tests/plugins/memory/test_openviking_provider.py index 92f724a39a8..b751da36b1f 100644 --- a/tests/plugins/memory/test_openviking_provider.py +++ b/tests/plugins/memory/test_openviking_provider.py @@ -1,10 +1,13 @@ import json +import os +import stat import zipfile from types import SimpleNamespace from unittest.mock import MagicMock import pytest +import plugins.memory.openviking as openviking_module from plugins.memory.openviking import ( OpenVikingMemoryProvider, _DEFERRED_COMMIT_TIMEOUT, @@ -17,6 +20,1123 @@ def _clear_openviking_tenant_env(monkeypatch): monkeypatch.delenv(name, raising=False) +@pytest.fixture(autouse=True) +def _isolate_openviking_home(tmp_path, monkeypatch): + home = tmp_path / "home" + monkeypatch.setattr(openviking_module.Path, "home", staticmethod(lambda: home)) + + +def _clear_openviking_env(monkeypatch): + for key in ( + "OPENVIKING_ENDPOINT", + "OPENVIKING_API_KEY", + "OPENVIKING_ACCOUNT", + "OPENVIKING_USER", + "OPENVIKING_AGENT", + "OPENVIKING_CLI_CONFIG_FILE", + ): + monkeypatch.delenv(key, raising=False) + + +def _prompt_from_values(values: dict[str, str], *, forbidden: set[str] | None = None): + forbidden = forbidden or set() + + def _prompt(label, default=None, secret=False): + if label in forbidden: + raise AssertionError(f"{label} should not be prompted") + return values.get(label, default or "") + + return _prompt + + +def _allow_setup_validation(monkeypatch, *, root_access: bool = False): + monkeypatch.setattr( + openviking_module, + "_validate_openviking_reachability", + lambda endpoint: (True, ""), + raising=False, + ) + monkeypatch.setattr( + openviking_module, + "_validate_openviking_auth", + lambda values: (True, ""), + raising=False, + ) + monkeypatch.setattr( + openviking_module, + "_validate_openviking_root_access", + lambda values: (root_access, "" if root_access else "Requires role: root"), + raising=False, + ) + monkeypatch.setattr( + openviking_module, + "_validate_openviking_setup_values", + lambda values, *, require_api_key=False: ( + True, + "", + "root" if root_access else ("user" if values.get("api_key") else None), + ), + raising=False, + ) + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes") +def test_openviking_env_writer_restricts_file_permissions(tmp_path): + env_path = tmp_path / ".env" + + openviking_module._write_env_vars(env_path, {"OPENVIKING_API_KEY": "secret"}) + + assert stat.S_IMODE(env_path.stat().st_mode) == 0o600 + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes") +def test_ovcli_config_writer_restricts_file_permissions(tmp_path): + config_path = tmp_path / "ovcli.conf" + + openviking_module._write_ovcli_config( + config_path, + {"endpoint": "http://remote.example", "api_key": "secret"}, + ) + + assert stat.S_IMODE(config_path.stat().st_mode) == 0o600 + + +def test_secret_permission_restriction_logs_chmod_failure(tmp_path, monkeypatch, caplog): + env_path = tmp_path / ".env" + env_path.write_text("OPENVIKING_API_KEY=secret\n", encoding="utf-8") + + def fail_chmod(self, mode): + raise OSError("read-only filesystem") + + monkeypatch.setattr(type(env_path), "chmod", fail_chmod) + + with caplog.at_level("DEBUG", logger=openviking_module.__name__): + openviking_module._restrict_secret_file_permissions(env_path) + + assert "Could not restrict permissions" in caplog.text + assert "read-only filesystem" in caplog.text + + +def test_linked_ovcli_config_is_read_at_runtime(tmp_path, monkeypatch): + _clear_openviking_env(monkeypatch) + ovcli_path = tmp_path / "ovcli.conf" + ovcli_path.write_text( + json.dumps({ + "url": "http://openviking-one.local", + "api_key": "key-one", + "account": "acct-one", + "user": "alice", + "agent_id": "agent-one", + }), + encoding="utf-8", + ) + provider_config = {"use_ovcli_config": True, "ovcli_config_path": str(ovcli_path)} + + settings = openviking_module._resolve_connection_settings(provider_config) + + assert settings == { + "endpoint": "http://openviking-one.local", + "api_key": "key-one", + "account": "", + "user": "", + "agent": "agent-one", + } + + ovcli_path.write_text( + json.dumps({ + "url": "http://openviking-two.local", + "api_key": "key-two", + "agent_id": "agent-two", + }), + encoding="utf-8", + ) + + settings = openviking_module._resolve_connection_settings(provider_config) + + assert settings == { + "endpoint": "http://openviking-two.local", + "api_key": "key-two", + "account": "", + "user": "", + "agent": "agent-two", + } + + +def test_openviking_env_overrides_linked_ovcli_config(tmp_path, monkeypatch): + _clear_openviking_env(monkeypatch) + ovcli_path = tmp_path / "ovcli.conf" + ovcli_path.write_text( + json.dumps({ + "url": "http://openviking.local", + "api_key": "file-key", + "account": "file-account", + "user": "file-user", + "agent_id": "file-agent", + }), + encoding="utf-8", + ) + monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://env.local") + monkeypatch.setenv("OPENVIKING_API_KEY", "env-key") + monkeypatch.setenv("OPENVIKING_ACCOUNT", "env-account") + monkeypatch.setenv("OPENVIKING_USER", "env-user") + monkeypatch.setenv("OPENVIKING_AGENT", "env-agent") + + settings = openviking_module._resolve_connection_settings({ + "use_ovcli_config": True, + "ovcli_config_path": str(ovcli_path), + }) + + assert settings == { + "endpoint": "http://env.local", + "api_key": "env-key", + "account": "env-account", + "user": "env-user", + "agent": "env-agent", + } + + +def test_openviking_cli_config_env_overrides_saved_profile_path(tmp_path, monkeypatch): + _clear_openviking_env(monkeypatch) + saved_path = tmp_path / "ovcli.conf.saved" + env_path = tmp_path / "ovcli.conf.env" + saved_path.write_text( + json.dumps({"url": "http://saved.local", "api_key": "saved-key"}), + encoding="utf-8", + ) + env_path.write_text( + json.dumps({"url": "http://env-profile.local", "api_key": "env-profile-key"}), + encoding="utf-8", + ) + monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(env_path)) + + settings = openviking_module._resolve_connection_settings({ + "use_ovcli_config": True, + "ovcli_config_path": str(saved_path), + }) + + assert settings["endpoint"] == "http://env-profile.local" + assert settings["api_key"] == "env-profile-key" + + +def test_connection_values_omit_stale_identity_for_user_key_with_root_key(): + values = openviking_module._connection_values_from_ovcli({ + "url": "https://openviking.example", + "api_key": "user-key", + "root_api_key": "root-key", + "account": "stale-account", + "user": "stale-user", + }) + + assert values["api_key"] == "user-key" + assert values["account"] == "" + assert values["user"] == "" + + +def test_discover_ovcli_profiles_lists_saved_profiles_without_active_label(tmp_path, monkeypatch): + _clear_openviking_env(monkeypatch) + openviking_home = tmp_path / ".openviking" + openviking_home.mkdir() + env_path = tmp_path / "custom-ovcli.conf" + env_path.write_text(json.dumps({"url": "http://env.local"}), encoding="utf-8") + (openviking_home / "ovcli.conf").write_text( + json.dumps({"url": "https://vps.example", "api_key": "secret"}), + encoding="utf-8", + ) + (openviking_home / "ovcli.conf.VPS").write_text( + json.dumps({"url": "https://vps.example", "api_key": "secret"}), + encoding="utf-8", + ) + (openviking_home / "ovcli.conf.bak").write_text( + json.dumps({"url": "http://backup.local"}), + encoding="utf-8", + ) + (openviking_home / "ovcli.conf.bad").write_text("{", encoding="utf-8") + monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(env_path)) + monkeypatch.setattr(openviking_module.Path, "home", staticmethod(lambda: tmp_path)) + + profiles = openviking_module._discover_ovcli_profiles() + + assert [(profile.source, profile.name, profile.path) for profile in profiles] == [ + ("env", "OPENVIKING_CLI_CONFIG_FILE", env_path), + ("saved", "VPS", openviking_home / "ovcli.conf.VPS"), + ] + assert profiles[1].is_active is True + assert openviking_module._profile_display_name(profiles[1]) == "VPS" + assert "active" not in openviking_module._profile_description(profiles[1]).lower() + + +def test_link_ovcli_profile_removes_stale_inline_config(tmp_path): + env_path = tmp_path / ".env" + env_path.write_text("OPENVIKING_ENDPOINT=http://old.local\nOTHER_KEY=keep\n", encoding="utf-8") + config = {"memory": {}} + provider_config = { + "use_ovcli_config": False, + "endpoint": "http://stale.local", + "api_key": "stale-key", + "account": "default", + "user": "default", + "agent": "stale-agent", + "api_key_type": "root", + } + ovcli_path = tmp_path / "ovcli.conf.VPS_ROOT" + + openviking_module._link_ovcli_profile( + config=config, + provider_config=provider_config, + env_path=env_path, + ovcli_path=ovcli_path, + ) + + assert config["memory"]["openviking"] == { + "use_ovcli_config": True, + "ovcli_config_path": str(ovcli_path), + } + assert "OPENVIKING_ENDPOINT" not in env_path.read_text(encoding="utf-8") + assert "OTHER_KEY=keep" in env_path.read_text(encoding="utf-8") + + +def test_post_setup_existing_profile_picker_validates_and_links_saved_profile(tmp_path, monkeypatch): + _clear_openviking_env(monkeypatch) + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + env_path = hermes_home / ".env" + env_path.write_text("OPENVIKING_ENDPOINT=http://old.local\nOTHER_KEY=keep\n", encoding="utf-8") + openviking_home = tmp_path / ".openviking" + openviking_home.mkdir() + active_path = openviking_home / "ovcli.conf" + saved_path = openviking_home / "ovcli.conf.VPS" + active_path.write_text(json.dumps({"url": "http://active.local"}), encoding="utf-8") + saved_path.write_text( + json.dumps({"url": "https://vps.example", "api_key": "user-key"}), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setattr(openviking_module.Path, "home", staticmethod(lambda: tmp_path)) + + from hermes_cli import memory_setup + + validate_calls = [] + + def validate_values(values, *, require_api_key=False): + validate_calls.append(dict(values)) + return True, "", "user" + + monkeypatch.setattr( + openviking_module, + "_validate_openviking_setup_values", + validate_values, + raising=False, + ) + choices = iter([0, 0]) + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(choices)) + config = {"memory": {}} + + OpenVikingMemoryProvider().post_setup(str(hermes_home), config) + + assert validate_calls == [{ + "endpoint": "https://vps.example", + "api_key": "user-key", + "root_api_key": "", + "account": "", + "user": "", + "agent": "", + }] + assert config["memory"]["provider"] == "openviking" + assert config["memory"]["openviking"] == { + "use_ovcli_config": True, + "ovcli_config_path": str(saved_path), + } + env_text = env_path.read_text(encoding="utf-8") + assert "OPENVIKING_" not in env_text + assert "OTHER_KEY=keep" in env_text + + +def test_post_setup_create_remote_user_profile_can_mirror_to_openviking_store(tmp_path, monkeypatch): + _clear_openviking_env(monkeypatch) + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setattr(openviking_module.Path, "home", staticmethod(lambda: tmp_path)) + _allow_setup_validation(monkeypatch) + + from hermes_cli import memory_setup + + choices = iter([1, 0, 1]) + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(choices)) + monkeypatch.setattr( + memory_setup, + "_prompt", + _prompt_from_values({ + "OpenViking server URL": "https://openviking.example", + "OpenViking user API key": "user-secret", + "OpenViking agent": "hermes", + "OpenViking profile name": "VPS", + }), + ) + config = {"memory": {}} + + OpenVikingMemoryProvider().post_setup(str(hermes_home), config) + + mirrored_path = tmp_path / ".openviking" / "ovcli.conf.VPS" + assert mirrored_path.exists() + assert json.loads(mirrored_path.read_text(encoding="utf-8")) == { + "url": "https://openviking.example", + "api_key": "user-secret", + "actor_peer_id": "hermes", + } + assert config["memory"]["provider"] == "openviking" + assert config["memory"]["openviking"] == { + "use_ovcli_config": True, + "ovcli_config_path": str(mirrored_path), + } + env_path = hermes_home / ".env" + if env_path.exists(): + assert "OPENVIKING_" not in env_path.read_text(encoding="utf-8") + + +def test_post_setup_create_remote_user_can_keep_hermes_only(tmp_path, monkeypatch): + _clear_openviking_env(monkeypatch) + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + _allow_setup_validation(monkeypatch) + + from hermes_cli import memory_setup + + choices = iter([1, 0, 0]) + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(choices)) + monkeypatch.setattr( + memory_setup, + "_prompt", + _prompt_from_values({ + "OpenViking server URL": "https://openviking.example", + "OpenViking user API key": "user-secret", + "OpenViking agent": "agent", + }), + ) + config = {"memory": {}} + + OpenVikingMemoryProvider().post_setup(str(hermes_home), config) + + assert config["memory"]["provider"] == "openviking" + assert config["memory"]["openviking"] == {"use_ovcli_config": False} + env_text = (hermes_home / ".env").read_text(encoding="utf-8") + assert "OPENVIKING_ENDPOINT=https://openviking.example" in env_text + assert "OPENVIKING_API_KEY=user-secret" in env_text + assert "OPENVIKING_AGENT=agent" in env_text + assert not (tmp_path / "home" / ".openviking").exists() + + +def test_post_setup_create_openviking_service_validates_after_api_key(tmp_path, monkeypatch): + _clear_openviking_env(monkeypatch) + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + from hermes_cli import memory_setup + + validation_calls = [] + + def validate_values(values, *, require_api_key=False): + validation_calls.append((dict(values), require_api_key)) + return True, "", "user" + + monkeypatch.setattr( + openviking_module, + "_validate_openviking_reachability", + MagicMock(side_effect=AssertionError("service setup validates only after API key entry")), + ) + monkeypatch.setattr(openviking_module, "_validate_openviking_setup_values", validate_values) + choices = iter([0, 0]) + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(choices)) + monkeypatch.setattr( + memory_setup, + "_prompt", + _prompt_from_values( + { + "OpenViking API key": "service-secret", + "OpenViking agent": "agent", + }, + forbidden={"OpenViking server URL", "OpenViking user API key", "OpenViking root API key"}, + ), + ) + config = {"memory": {}} + + OpenVikingMemoryProvider().post_setup(str(hermes_home), config) + + assert validation_calls == [( + { + "endpoint": "https://api.vikingdb.cn-beijing.volces.com/openviking", + "api_key": "service-secret", + "root_api_key": "", + "account": "", + "user": "", + "agent": "agent", + "api_key_type": "user", + }, + True, + )] + env_text = (hermes_home / ".env").read_text(encoding="utf-8") + assert "OPENVIKING_ENDPOINT=https://api.vikingdb.cn-beijing.volces.com/openviking" in env_text + assert "OPENVIKING_API_KEY=service-secret" in env_text + assert "OPENVIKING_AGENT=agent" in env_text + + +def test_post_setup_remote_blank_api_key_cancels_without_saving(tmp_path, monkeypatch): + _clear_openviking_env(monkeypatch) + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setattr(openviking_module, "_validate_openviking_reachability", lambda endpoint: (True, "")) + + from hermes_cli import config as hermes_config + from hermes_cli import memory_setup + + save_config = MagicMock() + monkeypatch.setattr(hermes_config, "save_config", save_config) + choices = iter([1, 0, 1]) + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(choices)) + monkeypatch.setattr( + memory_setup, + "_prompt", + _prompt_from_values({ + "OpenViking server URL": "https://openviking.example", + "OpenViking user API key": "", + }), + ) + config = {"memory": {"provider": "builtin"}} + + OpenVikingMemoryProvider().post_setup(str(hermes_home), config) + + save_config.assert_not_called() + assert config == {"memory": {"provider": "builtin"}} + assert not (hermes_home / ".env").exists() + + +def test_post_setup_user_key_path_can_route_detected_root_key_to_root_setup(tmp_path, monkeypatch): + _clear_openviking_env(monkeypatch) + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + from hermes_cli import memory_setup + + def validate_values(values, *, require_api_key=False): + assert values["api_key"] == "root-secret" + return True, "", "root" + + monkeypatch.setattr(openviking_module, "_validate_openviking_reachability", lambda endpoint: (True, "")) + monkeypatch.setattr(openviking_module, "_validate_openviking_setup_values", validate_values) + choices = iter([1, 0, 0, 0]) + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(choices)) + prompt_events = [] + + def fake_prompt(label, default=None, secret=False): + if label == "OpenViking root API key": + raise AssertionError("OpenViking root API key should not be re-prompted") + prompt_events.append(label) + values = { + "OpenViking server URL": "https://openviking.example", + "OpenViking user API key": "root-secret", + "OpenViking account": "acct", + "OpenViking user": "alice", + "OpenViking agent": "agent", + } + return values.get(label, default or "") + + monkeypatch.setattr(memory_setup, "_prompt", fake_prompt) + config = {"memory": {}} + + OpenVikingMemoryProvider().post_setup(str(hermes_home), config) + + assert prompt_events.count("OpenViking agent") == 1 + env_text = (hermes_home / ".env").read_text(encoding="utf-8") + assert "OPENVIKING_API_KEY=root-secret" in env_text + assert "OPENVIKING_ACCOUNT=acct" in env_text + assert "OPENVIKING_USER=alice" in env_text + assert "OPENVIKING_AGENT=agent" in env_text + + +def test_post_setup_root_key_path_can_route_detected_user_key_to_user_setup(tmp_path, monkeypatch): + _clear_openviking_env(monkeypatch) + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + from hermes_cli import memory_setup + + def validate_values(values, *, require_api_key=False): + assert values["api_key"] == "user-secret" + return True, "", "user" + + monkeypatch.setattr(openviking_module, "_validate_openviking_reachability", lambda endpoint: (True, "")) + monkeypatch.setattr(openviking_module, "_validate_openviking_setup_values", validate_values) + choices = iter([1, 1, 0, 0]) + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(choices)) + monkeypatch.setattr( + memory_setup, + "_prompt", + _prompt_from_values( + { + "OpenViking server URL": "https://openviking.example", + "OpenViking root API key": "user-secret", + "OpenViking agent": "agent", + }, + forbidden={"OpenViking user API key", "OpenViking account", "OpenViking user"}, + ), + ) + config = {"memory": {}} + + OpenVikingMemoryProvider().post_setup(str(hermes_home), config) + + env_text = (hermes_home / ".env").read_text(encoding="utf-8") + assert "OPENVIKING_API_KEY=user-secret" in env_text + assert "OPENVIKING_AGENT=agent" in env_text + assert "OPENVIKING_ACCOUNT" not in env_text + assert "OPENVIKING_USER" not in env_text + + +def test_manual_root_key_flow_prints_validation_progress(monkeypatch, capsys): + _clear_openviking_env(monkeypatch) + + monkeypatch.setattr(openviking_module, "_validate_openviking_reachability", lambda endpoint: (True, "")) + + validate_calls = [] + + def validate_values(values, *, require_api_key=False): + validate_calls.append(dict(values)) + return True, "", "root" + + monkeypatch.setattr(openviking_module, "_validate_openviking_setup_values", validate_values) + choices = iter([1]) + + values = openviking_module._prompt_manual_connection_values( + _prompt_from_values({ + "OpenViking server URL": "https://openviking.example", + "OpenViking root API key": "root-secret", + "OpenViking account": "acct", + "OpenViking user": "alice", + "OpenViking agent": "agent", + }), + lambda *args, **kwargs: next(choices), + -1, + ) + + assert values["root_api_key"] == "root-secret" + assert len(validate_calls) == 2 + output = capsys.readouterr().out + assert "Checking OpenViking server..." in output + assert "Validating OpenViking root API key..." in output + assert "Validating OpenViking API access..." in output + + +def test_start_local_openviking_server_uses_endpoint_host_and_port(monkeypatch): + popen_calls = [] + + def fake_popen(args, **kwargs): + popen_calls.append((args, kwargs)) + return object() + + monkeypatch.setattr(openviking_module.shutil, "which", lambda name: "/usr/local/bin/openviking-server") + monkeypatch.setattr(openviking_module.subprocess, "Popen", fake_popen) + + started, message = openviking_module._start_local_openviking_server("http://127.0.0.1:1934") + + assert started is True + assert "127.0.0.1:1934" in message + args, kwargs = popen_calls[0] + assert args == ["/usr/local/bin/openviking-server", "--host", "127.0.0.1", "--port", "1934"] + assert kwargs["start_new_session"] is True + + +def test_start_local_openviking_server_writes_output_to_log(tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + popen_calls = [] + + class FakeProcess: + pass + + def fake_popen(args, **kwargs): + popen_calls.append((args, kwargs)) + assert kwargs["stdout"] is kwargs["stderr"] + assert kwargs["stdout"].name == str(hermes_home / "logs" / "openviking-server.log") + assert not kwargs["stdout"].closed + return FakeProcess() + + monkeypatch.setattr(openviking_module.shutil, "which", lambda name: "/usr/local/bin/openviking-server") + monkeypatch.setattr(openviking_module.subprocess, "Popen", fake_popen) + + started, message = openviking_module._start_local_openviking_server("http://127.0.0.1:1934") + + assert started is True + assert str(hermes_home / "logs" / "openviking-server.log") in message + assert popen_calls + + +def test_https_local_endpoint_is_not_runtime_autostart_eligible(monkeypatch): + _clear_openviking_env(monkeypatch) + monkeypatch.setenv("OPENVIKING_ENDPOINT", "https://localhost:1934") + + class FakeVikingClient: + def __init__(self, endpoint, api_key="", account="", user="", agent=""): + assert endpoint == "https://localhost:1934" + + def health(self): + return False + + monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient) + monkeypatch.setattr( + openviking_module, + "_start_local_openviking_server", + MagicMock(side_effect=AssertionError("https localhost endpoint should not auto-start")), + ) + + warnings = [] + provider = OpenVikingMemoryProvider() + provider.initialize("session-1", platform="cli", warning_callback=warnings.append) + + assert provider._client is None + assert warnings == [ + "Remote OpenViking server at https://localhost:1934 is not reachable; " + "OpenViking memory disabled for this Hermes run. " + "Check the configured endpoint and network connectivity." + ] + + +def test_runtime_does_not_autostart_when_local_server_reports_unhealthy(monkeypatch): + _clear_openviking_env(monkeypatch) + monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://localhost:1934") + + class FakeVikingClient: + def __init__(self, endpoint, api_key="", account="", user="", agent=""): + assert endpoint == "http://localhost:1934" + + def health(self): + return False + + def health_payload(self): + return {"healthy": False} + + monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient) + monkeypatch.setattr( + openviking_module, + "_start_local_openviking_server", + MagicMock(side_effect=AssertionError("responding unhealthy server should not auto-start another process")), + ) + + warnings = [] + provider = OpenVikingMemoryProvider() + provider.initialize("session-1", platform="cli", warning_callback=warnings.append) + + assert provider._client is None + assert warnings == [ + "OpenViking server at http://localhost:1934 responded but reported unhealthy status. " + "OpenViking memory disabled for this Hermes run." + ] + + +def test_handle_unreachable_endpoint_does_not_wait_when_autostart_command_missing(monkeypatch, capsys): + monkeypatch.setattr( + openviking_module, + "_start_local_openviking_server", + lambda endpoint: (False, "openviking-server was not found on PATH."), + ) + monkeypatch.setattr( + openviking_module, + "_wait_for_openviking_health", + MagicMock(side_effect=AssertionError("should not wait when server did not start")), + ) + + result = openviking_module._handle_unreachable_endpoint( + "http://127.0.0.1:1934", + "OpenViking server is not reachable.", + lambda *args, **kwargs: 0, + -1, + ) + + assert result is False + output = capsys.readouterr().out + assert "openviking-server was not found on PATH." in output + assert "did not become reachable" not in output + + +def test_handle_unreachable_endpoint_waits_long_enough_after_autostart(monkeypatch, capsys): + wait_calls = [] + + monkeypatch.setattr( + openviking_module, + "_start_local_openviking_server", + lambda endpoint: (True, "Started openviking-server on 127.0.0.1:1934 in the background."), + ) + monkeypatch.setattr( + openviking_module, + "_wait_for_openviking_health", + lambda endpoint, *, timeout_seconds=0: wait_calls.append((endpoint, timeout_seconds)) or True, + ) + + result = openviking_module._handle_unreachable_endpoint( + "http://127.0.0.1:1934", + "OpenViking server is not reachable.", + lambda *args, **kwargs: 0, + -1, + ) + + assert result is True + assert wait_calls == [("http://127.0.0.1:1934", 60.0)] + output = capsys.readouterr().out + assert "Waiting for OpenViking server to become reachable..." in output + + +def test_manual_setup_does_not_offer_autostart_when_local_server_is_unhealthy(monkeypatch): + _clear_openviking_env(monkeypatch) + + class FakeVikingClient: + def __init__(self, endpoint, api_key="", account="", user="", agent=""): + assert endpoint == "http://localhost:1933" + + def health_payload(self): + return {"healthy": False} + + select_calls = [] + + def select(title, options, **kwargs): + select_calls.append((title, options)) + assert all(label != "Start local OpenViking" for label, _description in options) + return 1 + + monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient) + monkeypatch.setattr( + openviking_module, + "_start_local_openviking_server", + MagicMock(side_effect=AssertionError("unhealthy local server should not offer auto-start")), + ) + + result = openviking_module._prompt_manual_connection_values( + _prompt_from_values({"OpenViking server URL": "localhost"}), + select, + -1, + ) + + assert result is openviking_module._SETUP_CANCELLED + assert select_calls == [( + " OpenViking server unhealthy", + [ + ("Retry", "try this step again"), + ("Cancel setup", "no changes saved"), + ], + )] + + +def test_initialize_autostarts_local_openviking_in_background_when_runtime_health_fails(monkeypatch): + _clear_openviking_env(monkeypatch) + monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://127.0.0.1:1934") + health_calls = [] + start_calls = [] + waiter_calls = [] + + class FakeVikingClient: + def __init__(self, endpoint, api_key="", account="", user="", agent=""): + assert endpoint == "http://127.0.0.1:1934" + + def health(self): + health_calls.append("health") + return False + + monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient) + monkeypatch.setattr( + openviking_module, + "_start_local_openviking_server", + lambda endpoint: start_calls.append(endpoint) or (True, "started"), + ) + monkeypatch.setattr( + openviking_module, + "_wait_for_openviking_health", + MagicMock(side_effect=AssertionError("runtime init should not wait synchronously")), + ) + + provider = OpenVikingMemoryProvider() + monkeypatch.setattr( + provider, + "_start_runtime_openviking_waiter", + lambda **kwargs: waiter_calls.append(kwargs), + raising=False, + ) + statuses = [] + provider.initialize("session-1", platform="cli", status_callback=statuses.append) + + assert provider._client is None + assert health_calls == ["health"] + assert start_calls == ["http://127.0.0.1:1934"] + assert len(waiter_calls) == 1 + assert waiter_calls[0]["status_callback"] == statuses.append + assert any("starting in the background" in message for message in statuses) + + +def test_runtime_openviking_waiter_attaches_client_after_health_recovers(monkeypatch): + _clear_openviking_env(monkeypatch) + wait_calls = [] + + class FakeVikingClient: + def __init__(self, endpoint, api_key="", account="", user="", agent=""): + self.endpoint = endpoint + self.api_key = api_key + self.account = account + self.user = user + self.agent = agent + + def health(self): + return True + + monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient) + monkeypatch.setattr( + openviking_module, + "_wait_for_openviking_health", + lambda endpoint, **kwargs: wait_calls.append((endpoint, kwargs)) or True, + ) + + provider = OpenVikingMemoryProvider() + provider._endpoint = "http://127.0.0.1:1934" + provider._api_key = "secret" + provider._account = "acct" + provider._user = "alice" + provider._agent = "hermes" + statuses = [] + + provider._finish_runtime_openviking_start( + status_callback=statuses.append, + warning_callback=None, + ) + + assert provider._client is not None + assert provider._client.endpoint == "http://127.0.0.1:1934" + assert provider._client.api_key == "secret" + assert wait_calls == [( + "http://127.0.0.1:1934", + {"timeout_seconds": openviking_module._LOCAL_OPENVIKING_AUTOSTART_TIMEOUT}, + )] + assert any("OpenViking memory is active" in message for message in statuses) + + +def test_runtime_openviking_waiter_warns_when_background_start_times_out(monkeypatch): + _clear_openviking_env(monkeypatch) + monkeypatch.setattr( + openviking_module, + "_wait_for_openviking_health", + lambda endpoint, **kwargs: False, + ) + monkeypatch.setattr( + openviking_module, + "_VikingClient", + MagicMock(side_effect=AssertionError("client should not be rebuilt before health recovers")), + ) + + provider = OpenVikingMemoryProvider() + provider._endpoint = "http://127.0.0.1:1934" + warnings = [] + + provider._finish_runtime_openviking_start( + status_callback=None, + warning_callback=warnings.append, + ) + + assert provider._client is None + assert warnings == [ + "Local OpenViking server at http://127.0.0.1:1934 is not reachable. " + "Tried to start openviking-server, but it did not become reachable " + "within 60 seconds. OpenViking memory disabled for this Hermes run." + ] + + +def test_initialize_does_not_autostart_remote_openviking(monkeypatch, caplog): + _clear_openviking_env(monkeypatch) + monkeypatch.setenv("OPENVIKING_ENDPOINT", "https://openviking.example") + + class FakeVikingClient: + def __init__(self, endpoint, api_key="", account="", user="", agent=""): + assert endpoint == "https://openviking.example" + + def health(self): + return False + + monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient) + monkeypatch.setattr( + openviking_module, + "_start_local_openviking_server", + MagicMock(side_effect=AssertionError("remote endpoint should not auto-start")), + ) + monkeypatch.setattr( + openviking_module, + "_wait_for_openviking_health", + MagicMock(side_effect=AssertionError("remote endpoint should not wait")), + ) + + with caplog.at_level("WARNING", logger=openviking_module.__name__): + provider = OpenVikingMemoryProvider() + provider.initialize("session-1") + + assert provider._client is None + assert "Remote OpenViking server at https://openviking.example is not reachable" in caplog.text + + +def test_initialize_warns_clearly_when_local_runtime_autostart_fails(monkeypatch, caplog): + _clear_openviking_env(monkeypatch) + monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://localhost:1934") + + class FakeVikingClient: + def __init__(self, endpoint, api_key="", account="", user="", agent=""): + assert endpoint == "http://localhost:1934" + + def health(self): + return False + + monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient) + monkeypatch.setattr( + openviking_module, + "_start_local_openviking_server", + lambda endpoint: (False, "openviking-server was not found on PATH."), + ) + monkeypatch.setattr( + openviking_module, + "_wait_for_openviking_health", + MagicMock(side_effect=AssertionError("should not wait when server did not start")), + ) + + with caplog.at_level("WARNING", logger=openviking_module.__name__): + provider = OpenVikingMemoryProvider() + provider.initialize("session-1") + + assert provider._client is None + assert "Local OpenViking server at http://localhost:1934 is not reachable" in caplog.text + assert "openviking-server was not found on PATH" in caplog.text + + +def test_initialize_emits_cli_warning_when_local_runtime_autostart_fails(monkeypatch): + _clear_openviking_env(monkeypatch) + monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://localhost:1934") + + class FakeVikingClient: + def __init__(self, endpoint, api_key="", account="", user="", agent=""): + assert endpoint == "http://localhost:1934" + + def health(self): + return False + + warnings = [] + monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient) + monkeypatch.setattr( + openviking_module, + "_start_local_openviking_server", + lambda endpoint: (False, "openviking-server was not found on PATH."), + ) + + provider = OpenVikingMemoryProvider() + provider.initialize("session-1", platform="cli", warning_callback=warnings.append) + + assert provider._client is None + assert warnings == [ + "Local OpenViking server at http://localhost:1934 is not reachable. " + "openviking-server was not found on PATH. " + "OpenViking memory disabled for this Hermes run." + ] + + +def test_initialize_does_not_emit_cli_warning_when_callback_absent(monkeypatch): + _clear_openviking_env(monkeypatch) + monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://localhost:1934") + + class FakeVikingClient: + def __init__(self, endpoint, api_key="", account="", user="", agent=""): + assert endpoint == "http://localhost:1934" + + def health(self): + return False + + monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient) + monkeypatch.setattr( + openviking_module, + "_start_local_openviking_server", + lambda endpoint: (False, "openviking-server was not found on PATH."), + ) + + provider = OpenVikingMemoryProvider() + provider.initialize("session-1", platform="gateway") + + assert provider._client is None + + +def test_post_setup_local_server_down_can_offer_autostart(tmp_path, monkeypatch): + _clear_openviking_env(monkeypatch) + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setattr(openviking_module, "_validate_openviking_setup_values", lambda values, *, require_api_key=False: (True, "", None)) + + from hermes_cli import memory_setup + + reachability_calls = [] + + def validate_reachability(endpoint): + reachability_calls.append(endpoint) + return False, "OpenViking server is not reachable." if len(reachability_calls) == 1 else "" + + started = [] + monkeypatch.setattr(openviking_module, "_validate_openviking_reachability", validate_reachability) + monkeypatch.setattr(openviking_module, "_start_local_openviking_server", lambda endpoint: (started.append(endpoint) or True, "started")) + monkeypatch.setattr(openviking_module, "_wait_for_openviking_health", lambda endpoint, **kwargs: True) + choices = iter([1, 0, 0, 0]) + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(choices)) + monkeypatch.setattr( + memory_setup, + "_prompt", + _prompt_from_values({ + "OpenViking server URL": "localhost", + "OpenViking agent": "agent", + }), + ) + config = {"memory": {}} + + OpenVikingMemoryProvider().post_setup(str(hermes_home), config) + + assert started == ["http://localhost:1933"] + assert reachability_calls == ["http://localhost:1933"] + env_text = (hermes_home / ".env").read_text(encoding="utf-8") + assert "OPENVIKING_ENDPOINT=http://localhost:1933" in env_text + assert "OPENVIKING_API_KEY" not in env_text + + +def test_post_setup_invalid_env_profile_can_create_new_config(tmp_path, monkeypatch): + _clear_openviking_env(monkeypatch) + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + ovcli_path = tmp_path / "broken" / "ovcli.conf" + ovcli_path.parent.mkdir() + ovcli_path.write_text("{", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path)) + _allow_setup_validation(monkeypatch) + + from hermes_cli import memory_setup + + choices = iter([1, 0, 0]) + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(choices)) + monkeypatch.setattr( + memory_setup, + "_prompt", + _prompt_from_values({ + "OpenViking server URL": "https://openviking.example", + "OpenViking user API key": "user-secret", + "OpenViking agent": "agent", + }), + ) + config = {"memory": {}} + + OpenVikingMemoryProvider().post_setup(str(hermes_home), config) + + assert ovcli_path.read_text(encoding="utf-8") == "{" + assert config["memory"]["openviking"] == {"use_ovcli_config": False} + + def test_tool_search_sorts_by_raw_score_across_buckets(): provider = OpenVikingMemoryProvider() provider._client = MagicMock() @@ -339,6 +1459,7 @@ def test_viking_client_upload_temp_file_uses_multipart_identity_headers(tmp_path headers = captured_kwargs["headers"] assert headers["X-OpenViking-Account"] == "test-account" assert headers["X-OpenViking-User"] == "test-user" + assert headers["X-OpenViking-Actor-Peer"] == "test-agent" assert headers["X-OpenViking-Agent"] == "test-agent" assert headers["X-API-Key"] == "test-key" assert "Content-Type" not in headers @@ -363,6 +1484,28 @@ def test_viking_client_raises_structured_server_error(): client._parse_response(response) +def test_viking_client_sanitizes_html_error_body(): + client = _VikingClient.__new__(_VikingClient) + response = SimpleNamespace( + status_code=523, + text=""" + +tosaki.top | 523: Origin is unreachable +large Cloudflare error page +""", + json=lambda: (_ for _ in ()).throw(ValueError("not json")), + ) + + with pytest.raises(openviking_module._OpenVikingHTTPError) as exc_info: + client._parse_response(response) + + message = str(exc_info.value) + assert "HTTP 523" in message + assert "Origin is unreachable" in message + assert "