diff --git a/agent/agent_init.py b/agent/agent_init.py index 2c2ded871e5..14311d8c0db 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1153,6 +1153,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 cd2a1b4406f..c1b058adaeb 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -44,7 +44,9 @@ def _curses_select( f"{label} - {desc}" if desc else label for label, desc in items ] - return curses_radiolist(title, display_items, selected=default, cancel_returns=cancel_returns) + result = curses_radiolist(title, display_items, selected=default, cancel_returns=cancel_returns) + _clear_interactive_transition() + return result def _print_cancelled_setup() -> None: @@ -229,6 +231,8 @@ def cmd_setup_provider(provider_name: str) -> None: name, _, provider = match + _clear_interactive_transition() + _install_dependencies(name) config = load_config() @@ -439,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/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 1bd1dc1262d..07dd3317957 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -30,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, Dict, List, Optional from urllib.parse import urlparse @@ -46,11 +51,13 @@ 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", @@ -80,9 +87,57 @@ _MEMORY_WRITE_TARGET_SUBDIR_MAP = { "memory": "patterns", } _LOCAL_OPENVIKING_HOSTS = {"localhost", "127.0.0.1", "::1"} +_LOCAL_OPENVIKING_AUTOSTART_TIMEOUT = 60.0 _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) + + # --------------------------------------------------------------------------- # Process-level atexit safety net — ensures pending sessions are committed # even if shutdown_memory_provider is never called (e.g. gateway crash, @@ -138,6 +193,7 @@ class _VikingClient: def _headers(self) -> dict: 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 @@ -163,15 +219,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") @@ -223,6 +283,12 @@ 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") @@ -432,14 +498,18 @@ def _default_ovcli_config_path() -> Path: def _resolve_ovcli_config_path(config_path: str = "") -> Path: - if config_path: - return Path(config_path).expanduser() 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(): @@ -452,17 +522,143 @@ def _load_ovcli_config(path: Optional[Path] = None) -> dict: 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": _clean_config_value(data.get("url")) or _DEFAULT_ENDPOINT, - "api_key": _clean_config_value(data.get("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")), - "agent": _clean_config_value(data.get("agent_id")), + "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 = _clean_config_value(value) + candidate = _normalize_openviking_url(value) if not candidate: return False if "://" not in candidate: @@ -570,19 +766,22 @@ def _remember_ovcli_path(provider_config: dict, ovcli_path: Path) -> None: def _ovcli_data_from_connection_values(values: dict) -> dict: - data = {"url": _clean_config_value(values.get("endpoint")) or _DEFAULT_ENDPOINT} + 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["agent_id"] = agent + data["actor_peer_id"] = agent return data @@ -593,18 +792,24 @@ def _write_ovcli_config(path: Path, values: dict) -> None: def _validate_openviking_reachability(endpoint: str) -> tuple[bool, str]: - endpoint = _clean_config_value(endpoint) or _DEFAULT_ENDPOINT + endpoint = _normalize_openviking_url(endpoint) try: client = _VikingClient(endpoint) - if client.health(): + 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: - return False, f"OpenViking server is not reachable at {endpoint}: {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 = _clean_config_value(values.get("endpoint")) or _DEFAULT_ENDPOINT + endpoint = _normalize_openviking_url(values.get("endpoint")) try: client = _VikingClient( endpoint, @@ -615,12 +820,12 @@ def _validate_openviking_auth(values: dict) -> tuple[bool, str]: ) client.validate_auth() except Exception as e: - return False, f"OpenViking authentication validation failed: {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 = _clean_config_value(values.get("endpoint")) or _DEFAULT_ENDPOINT + endpoint = _normalize_openviking_url(values.get("endpoint")) try: client = _VikingClient( endpoint, @@ -629,7 +834,7 @@ def _validate_openviking_root_access(values: dict) -> tuple[bool, str]: ) client.validate_root_access() except Exception as e: - return False, f"OpenViking root API key validation failed: {e}" + return False, f"OpenViking root API key validation failed: {_format_openviking_exception(e)}" return True, "" @@ -644,6 +849,68 @@ def _validate_openviking_user_key_scope(values: dict) -> tuple[bool, str]: ) +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( @@ -660,34 +927,188 @@ def _retry_or_cancel_manual_setup(select, title: str, message: str, cancelled): return _SETUP_CANCELLED -def _prompt_manual_connection_values(prompt, select, 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 _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}" + try: + subprocess.Popen( + [server_cmd, "--host", host, "--port", str(port)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + 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." + + +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 _handle_unreachable_endpoint(endpoint: str, message: str, select, cancelled): + if _is_local_openviking_url(endpoint): + 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 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 _prompt_profile_name(prompt, select, cancelled) -> str | object: while True: - endpoint = _clean_config_value( - prompt("OpenViking server URL", default=_DEFAULT_ENDPOINT) - ) or _DEFAULT_ENDPOINT - reachable, message = _validate_openviking_reachability(endpoint) - if reachable: - print(" OpenViking server is reachable.") - break + name = _clean_config_value(prompt("OpenViking profile name")) + if _is_valid_ovcli_profile_name(name): + return name retry = _retry_or_cancel_manual_setup( select, - " OpenViking server unreachable", - message, + " 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) + 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 is_local: + if not api_key_type and is_local: credential_choice = select( " OpenViking credential", [ @@ -704,8 +1125,9 @@ def _prompt_manual_connection_values(prompt, select, cancelled): values["agent"] = _clean_config_value( prompt("OpenViking agent", default=_DEFAULT_AGENT) ) or _DEFAULT_AGENT - authenticated, message = _validate_openviking_auth(values) - if authenticated: + _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( @@ -718,7 +1140,7 @@ def _prompt_manual_connection_values(prompt, select, cancelled): return _SETUP_CANCELLED continue api_key_type = "root" if credential_choice == 2 else "user" - else: + elif not api_key_type: credential_choice = select( " OpenViking API key type", [ @@ -733,12 +1155,19 @@ def _prompt_manual_connection_values(prompt, select, cancelled): api_key_type = "root" if credential_choice == 1 else "user" values["api_key_type"] = api_key_type - api_key_label = ( - "OpenViking root API key" - if api_key_type == "root" - else "OpenViking user API key" - ) - values["api_key"] = _clean_config_value(prompt(api_key_label, secret=True)) + 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, @@ -751,8 +1180,30 @@ def _prompt_manual_connection_values(prompt, select, cancelled): continue if api_key_type == "root": - root_ok, message = _validate_openviking_root_access(values) + _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", @@ -763,36 +1214,75 @@ def _prompt_manual_connection_values(prompt, select, cancelled): return _SETUP_CANCELLED continue print(" OpenViking root API key validated.") - values["account"] = _clean_config_value(prompt("OpenViking account")) - values["user"] = _clean_config_value(prompt("OpenViking user")) - if not values["account"] or not values["user"]: + 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", - "Root API keys require both OpenViking account and user.", + 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 - - values["agent"] = _clean_config_value( - prompt("OpenViking agent", default=_DEFAULT_AGENT) - ) or _DEFAULT_AGENT - authenticated, message = _validate_openviking_auth(values) - if authenticated: - if api_key_type == "user": - user_key_ok, message = _validate_openviking_user_key_scope(values) - if not user_key_ok: - retry = _retry_or_cancel_manual_setup( - select, - " OpenViking user API key is root key", - message, - cancelled, - ) - if retry is _SETUP_CANCELLED: - return _SETUP_CANCELLED - continue print(" OpenViking API access validated.") return values retry = _retry_or_cancel_manual_setup( @@ -805,6 +1295,223 @@ def _prompt_manual_connection_values(prompt, select, 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 # --------------------------------------------------------------------------- @@ -822,6 +1529,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 @property def name(self) -> str: @@ -873,6 +1582,40 @@ 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 @@ -886,22 +1629,13 @@ class OpenVikingMemoryProvider(MemoryProvider): if not isinstance(provider_config, dict): provider_config = {} - ovcli_path = _resolve_ovcli_config_path(str(provider_config.get("ovcli_config_path") or "")) - - print("\n Configuring OpenViking memory:\n") - - if ovcli_path.exists(): - try: - ovcli_values = _connection_values_from_ovcli(_load_ovcli_config(ovcli_path)) - except Exception as e: - print(f"\n Could not read OpenViking CLI config: {e}") - print(" No changes saved.\n") - return + print("\n OpenViking memory setup\n") + profiles = _discover_ovcli_profiles() + if profiles: setup_options = [ - ("Link to ovcli.conf", "Hermes follows the active OpenViking CLI config"), - ("Copy once", "Hermes won't follow future ovcli.conf changes"), - ("Manual Setup", "Enter a new URL/API key"), + ("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", @@ -914,130 +1648,143 @@ class OpenVikingMemoryProvider(MemoryProvider): return if choice == 0: - provider_config["use_ovcli_config"] = True - _remember_ovcli_path(provider_config, ovcli_path) - _write_env_vars(env_path, {}, remove_keys=_OPENVIKING_ENV_KEYS) - config["memory"]["provider"] = "openviking" - config["memory"]["openviking"] = provider_config - save_config(config) - print(f"\n Memory provider: openviking") - print(f" Linked config: {ovcli_path}") - print(" Start a new session to activate.\n") - return - - if choice == 1: - provider_config["use_ovcli_config"] = False - provider_config.pop("ovcli_config_path", None) - config["memory"]["provider"] = "openviking" - config["memory"]["openviking"] = provider_config - save_config(config) - _write_env_vars( - env_path, - _env_writes_from_connection_values(ovcli_values), - remove_keys=_OPENVIKING_ENV_KEYS, + result = _run_existing_profile_setup( + profiles=profiles, + select=_curses_select, + cancelled=_CANCELLED, + config=config, + provider_config=provider_config, + env_path=env_path, ) - print(f"\n Memory provider: openviking") - print(" Connection saved to .env") - print(" Start a new session to activate.\n") + if result is _SETUP_CANCELLED: + _print_cancelled_setup() + return + if result: + save_config(config) return - values = _prompt_manual_connection_values(_prompt, _curses_select, _CANCELLED) - if values is _SETUP_CANCELLED: - _print_cancelled_setup() - return - if values is None: - return + else: + print(" No existing OpenViking CLI profiles found. Creating a new config.") - save_choice = _curses_select( - " Save OpenViking config", - [ - ("Write ovcli.conf and link", "Hermes and ov use this config"), - ("Keep within Hermes", "Write values only to Hermes .env"), - ], - default=1, - cancel_returns=_CANCELLED, - ) - if save_choice == _CANCELLED: - _print_cancelled_setup() - return - - config["memory"]["provider"] = "openviking" - if save_choice == 0: - _write_ovcli_config(ovcli_path, values) - provider_config["use_ovcli_config"] = True - _remember_ovcli_path(provider_config, ovcli_path) - config["memory"]["openviking"] = provider_config - save_config(config) - _write_env_vars(env_path, {}, remove_keys=_OPENVIKING_ENV_KEYS) - print(f"\n Memory provider: openviking") - print(f" Updated config: {ovcli_path}") - else: - provider_config["use_ovcli_config"] = False - provider_config.pop("ovcli_config_path", None) - config["memory"]["openviking"] = provider_config - save_config(config) - _write_env_vars( - env_path, - _env_writes_from_connection_values(values), - remove_keys=_OPENVIKING_ENV_KEYS, - ) - print(f"\n Memory provider: openviking") - print(" Connection saved to .env") - print(" Start a new session to activate.\n") - return - - setup_options = [ - ("Create ovcli.conf and link", "Recommended"), - ("Configure Hermes only", "Write OpenViking values to Hermes .env"), - ] - choice = _curses_select( - " OpenViking config source", - setup_options, - default=0, - cancel_returns=_CANCELLED, + result = _run_create_profile_setup( + prompt=_prompt, + select=_curses_select, + cancelled=_CANCELLED, + config=config, + provider_config=provider_config, + env_path=env_path, ) - if choice == _CANCELLED: + if result is _SETUP_CANCELLED: _print_cancelled_setup() return - - defaults = { - "endpoint": _DEFAULT_ENDPOINT, - "api_key": "", - "account": "", - "user": "", - "agent": _DEFAULT_AGENT, - } - values = { - "endpoint": _prompt("OpenViking server URL", default=defaults["endpoint"]), - "api_key": _prompt("OpenViking API key", secret=True), - "account": _prompt("OpenViking account", default=defaults["account"]), - "user": _prompt("OpenViking user", default=defaults["user"]), - "agent": _prompt("OpenViking agent", default=defaults["agent"]), - } - - config["memory"]["provider"] = "openviking" - if choice == 0: - _write_ovcli_config(ovcli_path, values) - provider_config["use_ovcli_config"] = True - _remember_ovcli_path(provider_config, ovcli_path) - config["memory"]["openviking"] = provider_config + if result: save_config(config) - _write_env_vars(env_path, {}, remove_keys=_OPENVIKING_ENV_KEYS) - print(f"\n Memory provider: openviking") - print(f" Created config: {ovcli_path}") - else: - provider_config["use_ovcli_config"] = False - provider_config.pop("ovcli_config_path", None) - config["memory"]["openviking"] = provider_config - save_config(config) - _write_env_vars( - env_path, - _env_writes_from_connection_values(values), - remove_keys=_OPENVIKING_ENV_KEYS, + + 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", ) - print(f"\n Memory provider: openviking") - print(" Connection saved to .env") - print(" Start a new session to activate.\n") + 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: settings = _resolve_connection_settings(_load_hermes_openviking_config()) @@ -1048,6 +1795,16 @@ class OpenVikingMemoryProvider(MemoryProvider): 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( @@ -1055,8 +1812,10 @@ class OpenVikingMemoryProvider(MemoryProvider): 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) - self._client = None + self._handle_runtime_openviking_unreachable( + status_callback=status_callback, + warning_callback=warning_callback, + ) except ImportError: logger.warning("httpx not installed — OpenViking plugin disabled") self._client = None diff --git a/tests/hermes_cli/test_memory_setup.py b/tests/hermes_cli/test_memory_setup.py index 1e75a5a2add..b5b574230c7 100644 --- a/tests/hermes_cli/test_memory_setup.py +++ b/tests/hermes_cli/test_memory_setup.py @@ -45,6 +45,22 @@ def test_curses_select_accepts_explicit_cancel_value(monkeypatch): 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")) @@ -95,6 +111,60 @@ def test_cmd_setup_clears_interactive_picker_before_provider_post_setup(monkeypa 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): 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_openviking_provider.py b/tests/plugins/memory/test_openviking_provider.py index 190f8ba1b78..36e0658f338 100644 --- a/tests/plugins/memory/test_openviking_provider.py +++ b/tests/plugins/memory/test_openviking_provider.py @@ -11,6 +11,12 @@ import plugins.memory.openviking as openviking_module from plugins.memory.openviking import OpenVikingMemoryProvider, _VikingClient +@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", @@ -53,6 +59,16 @@ def _allow_setup_validation(monkeypatch, *, root_access: bool = False): 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") @@ -112,8 +128,8 @@ def test_linked_ovcli_config_is_read_at_runtime(tmp_path, monkeypatch): assert settings == { "endpoint": "http://openviking-one.local", "api_key": "key-one", - "account": "acct-one", - "user": "alice", + "account": "", + "user": "", "agent": "agent-one", } @@ -170,101 +186,222 @@ def test_openviking_env_overrides_linked_ovcli_config(tmp_path, monkeypatch): } -def test_post_setup_link_existing_ovcli_clears_hermes_env(tmp_path, monkeypatch): +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\n" - "OPENVIKING_ACCOUNT=old-account\n" - "OTHER_KEY=keep\n", + 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", ) - ovcli_path = tmp_path / "ovcli.conf" - original_ovcli = json.dumps({"url": "http://openviking.local"}) - ovcli_path.write_text(original_ovcli, encoding="utf-8") monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path)) + monkeypatch.setattr(openviking_module.Path, "home", staticmethod(lambda: tmp_path)) from hermes_cli import memory_setup - monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: 0) + 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"] is True - assert config["memory"]["openviking"]["ovcli_config_path"] == str(ovcli_path) + 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 - assert ovcli_path.read_text(encoding="utf-8") == original_ovcli -def test_post_setup_copy_existing_ovcli_writes_hermes_env(tmp_path, monkeypatch): +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() - ovcli_path = tmp_path / "ovcli.conf" - original_ovcli = json.dumps({ - "url": "http://openviking.local", - "api_key": "test-key", - "account": "acct", - "user": "alice", - "agent_id": "agent", - }) - ovcli_path.write_text(original_ovcli, encoding="utf-8") monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path)) + monkeypatch.setattr(openviking_module.Path, "home", staticmethod(lambda: tmp_path)) + _allow_setup_validation(monkeypatch) from hermes_cli import memory_setup - monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: 1) - config = {"memory": {}} - - OpenVikingMemoryProvider().post_setup(str(hermes_home), config) - - assert config["memory"]["provider"] == "openviking" - assert config["memory"]["openviking"]["use_ovcli_config"] is False - env_text = (hermes_home / ".env").read_text(encoding="utf-8") - assert "OPENVIKING_ENDPOINT=http://openviking.local" in env_text - assert "OPENVIKING_API_KEY=test-key" in env_text - assert "OPENVIKING_ACCOUNT=acct" in env_text - assert "OPENVIKING_USER=alice" in env_text - assert "OPENVIKING_AGENT=agent" in env_text - assert ovcli_path.read_text(encoding="utf-8") == original_ovcli - - -def test_post_setup_manual_remote_root_writes_ovcli_and_links(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\n", encoding="utf-8") - ovcli_path = tmp_path / "ovcli.conf" - ovcli_path.write_text(json.dumps({"url": "http://old.local"}), encoding="utf-8") - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path)) - _allow_setup_validation(monkeypatch, root_access=True) - - from hermes_cli import memory_setup - - choices = iter([2, 1, 0]) - monkeypatch.setattr( - memory_setup, - "_curses_select", - lambda *args, **kwargs: next(choices), - ) + 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 root API key": "root-secret", - "OpenViking account": "acct", - "OpenViking user": "alice", + "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", }), ) @@ -273,378 +410,83 @@ def test_post_setup_manual_remote_root_writes_ovcli_and_links(tmp_path, monkeypa OpenVikingMemoryProvider().post_setup(str(hermes_home), config) assert config["memory"]["provider"] == "openviking" - assert config["memory"]["openviking"]["use_ovcli_config"] is True - assert config["memory"]["openviking"]["ovcli_config_path"] == str(ovcli_path) - assert env_path.read_text(encoding="utf-8") == "" - data = json.loads(ovcli_path.read_text(encoding="utf-8")) - assert data == { - "url": "https://openviking.example", - "api_key": "root-secret", - "account": "acct", - "user": "alice", - "agent_id": "agent", - } + 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_manual_remote_user_keeps_only_hermes_env(tmp_path, monkeypatch): +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() - ovcli_path = tmp_path / "ovcli.conf" - original_ovcli = json.dumps({"url": "http://old.local"}) - ovcli_path.write_text(original_ovcli, 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([2, 0, 1]) + validation_calls = [] + + def validate_values(values, *, require_api_key=False): + validation_calls.append((dict(values), require_api_key)) + return True, "", "user" + monkeypatch.setattr( - memory_setup, - "_curses_select", - lambda *args, **kwargs: next(choices), + 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 server URL": "https://openviking.example", - "OpenViking user API key": "user-secret", + "OpenViking API key": "service-secret", "OpenViking agent": "agent", }, - forbidden={ - "OpenViking account", - "OpenViking root API key", - "OpenViking user", - }, + forbidden={"OpenViking server URL", "OpenViking user API key", "OpenViking root API key"}, ), ) config = {"memory": {}} OpenVikingMemoryProvider().post_setup(str(hermes_home), config) - assert config["memory"]["provider"] == "openviking" - assert config["memory"]["openviking"]["use_ovcli_config"] is False - assert ovcli_path.read_text(encoding="utf-8") == original_ovcli + 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://openviking.example" in env_text - assert "OPENVIKING_API_KEY=user-secret" in env_text + 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 - assert "OPENVIKING_ACCOUNT" not in env_text - assert "OPENVIKING_USER" not in env_text -def test_post_setup_manual_validation_failure_writes_nothing(tmp_path, monkeypatch): +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() - ovcli_path = tmp_path / "ovcli.conf" - original_ovcli = json.dumps({"url": "http://old.local"}) - ovcli_path.write_text(original_ovcli, encoding="utf-8") monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path)) - _allow_setup_validation(monkeypatch) - monkeypatch.setattr( - openviking_module, - "_validate_openviking_auth", - lambda values: (False, "OpenViking authentication validation failed: bad key"), - raising=False, - ) - - from hermes_cli import config as hermes_config - from hermes_cli import memory_setup - - save_config = MagicMock() - choices = iter([2, 0, 1]) - monkeypatch.setattr(hermes_config, "save_config", save_config) - 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": "bad-key", - "OpenViking agent": "agent", - }), - ) - config = {"memory": {"provider": "builtin"}} - - OpenVikingMemoryProvider().post_setup(str(hermes_home), config) - - save_config.assert_not_called() - assert config == {"memory": {"provider": "builtin"}} - assert ovcli_path.read_text(encoding="utf-8") == original_ovcli - assert not (hermes_home / ".env").exists() - - -def test_post_setup_manual_retries_base_url_until_reachable(tmp_path, monkeypatch): - _clear_openviking_env(monkeypatch) - hermes_home = tmp_path / "hermes" - hermes_home.mkdir() - ovcli_path = tmp_path / "ovcli.conf" - ovcli_path.write_text(json.dumps({"url": "http://old.local"}), encoding="utf-8") - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path)) - monkeypatch.setattr(openviking_module, "_validate_openviking_auth", lambda values: (True, "")) - - reachability_calls = [] - - def validate_reachability(endpoint): - reachability_calls.append(endpoint) - if endpoint == "http://bad.local:1933": - return False, "OpenViking server is not reachable at http://bad.local:1933." - return True, "" - - monkeypatch.setattr(openviking_module, "_validate_openviking_reachability", validate_reachability) - monkeypatch.setattr(openviking_module, "_validate_openviking_root_access", lambda values: (False, "Requires role: root")) - - from hermes_cli import memory_setup - - prompts = { - "OpenViking server URL": iter(["http://bad.local:1933", "http://localhost:1933"]), - "OpenViking agent": iter(["agent"]), - } - - def fake_prompt(label, default=None, secret=False): - return next(prompts[label]) - - choices = iter([2, 0, 0, 1]) - monkeypatch.setattr( - memory_setup, - "_curses_select", - lambda *args, **kwargs: next(choices), - ) - monkeypatch.setattr(memory_setup, "_prompt", fake_prompt) - config = {"memory": {}} - - OpenVikingMemoryProvider().post_setup(str(hermes_home), config) - - assert reachability_calls == ["http://bad.local:1933", "http://localhost:1933"] - assert config["memory"]["provider"] == "openviking" - env_text = (hermes_home / ".env").read_text(encoding="utf-8") - assert "OPENVIKING_ENDPOINT=http://localhost:1933" in env_text - - -def test_post_setup_manual_retries_user_key_until_status_valid(tmp_path, monkeypatch): - _clear_openviking_env(monkeypatch) - hermes_home = tmp_path / "hermes" - hermes_home.mkdir() - ovcli_path = tmp_path / "ovcli.conf" - ovcli_path.write_text(json.dumps({"url": "http://old.local"}), encoding="utf-8") - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path)) monkeypatch.setattr(openviking_module, "_validate_openviking_reachability", lambda endpoint: (True, "")) - monkeypatch.setattr(openviking_module, "_validate_openviking_root_access", lambda values: (False, "Requires role: root")) - - auth_calls = [] - - def validate_auth(values): - auth_calls.append(dict(values)) - if values["api_key"] == "bad-key": - return False, "OpenViking authentication validation failed: bad key" - return True, "" - - monkeypatch.setattr(openviking_module, "_validate_openviking_auth", validate_auth) - - from hermes_cli import memory_setup - - prompts = { - "OpenViking server URL": iter(["https://openviking.example"]), - "OpenViking user API key": iter(["bad-key", "good-key"]), - "OpenViking agent": iter(["agent", "agent"]), - } - - def fake_prompt(label, default=None, secret=False): - return next(prompts[label]) - - choices = iter([2, 0, 0, 0, 1]) - monkeypatch.setattr( - memory_setup, - "_curses_select", - lambda *args, **kwargs: next(choices), - ) - monkeypatch.setattr(memory_setup, "_prompt", fake_prompt) - config = {"memory": {}} - - OpenVikingMemoryProvider().post_setup(str(hermes_home), config) - - assert [call["api_key"] for call in auth_calls] == ["bad-key", "good-key"] - env_text = (hermes_home / ".env").read_text(encoding="utf-8") - assert "OPENVIKING_API_KEY=good-key" in env_text - - -def test_post_setup_manual_user_key_rejects_root_key(tmp_path, monkeypatch): - _clear_openviking_env(monkeypatch) - hermes_home = tmp_path / "hermes" - hermes_home.mkdir() - ovcli_path = tmp_path / "ovcli.conf" - ovcli_path.write_text(json.dumps({"url": "http://old.local"}), encoding="utf-8") - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path)) - monkeypatch.setattr(openviking_module, "_validate_openviking_reachability", lambda endpoint: (True, "")) - monkeypatch.setattr(openviking_module, "_validate_openviking_auth", lambda values: (True, "")) - - root_checks = [] - - def validate_root(values): - root_checks.append(values["api_key"]) - if values["api_key"] == "root-secret": - return True, "" - return False, "Requires role: root" - - monkeypatch.setattr(openviking_module, "_validate_openviking_root_access", validate_root) - - from hermes_cli import memory_setup - - prompts = { - "OpenViking server URL": iter(["https://openviking.example"]), - "OpenViking user API key": iter(["root-secret", "user-secret"]), - "OpenViking agent": iter(["agent", "agent"]), - } - - def fake_prompt(label, default=None, secret=False): - return next(prompts[label]) - - choices = iter([2, 0, 0, 0, 1]) - monkeypatch.setattr( - memory_setup, - "_curses_select", - lambda *args, **kwargs: next(choices), - ) - monkeypatch.setattr(memory_setup, "_prompt", fake_prompt) - config = {"memory": {}} - - OpenVikingMemoryProvider().post_setup(str(hermes_home), config) - - assert root_checks == ["root-secret", "user-secret"] - env_text = (hermes_home / ".env").read_text(encoding="utf-8") - assert "OPENVIKING_API_KEY=user-secret" in env_text - assert "OPENVIKING_API_KEY=root-secret" not in env_text - - -def test_post_setup_manual_root_key_requires_root_only_validation(tmp_path, monkeypatch): - _clear_openviking_env(monkeypatch) - hermes_home = tmp_path / "hermes" - hermes_home.mkdir() - ovcli_path = tmp_path / "ovcli.conf" - ovcli_path.write_text(json.dumps({"url": "http://old.local"}), encoding="utf-8") - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path)) - monkeypatch.setattr(openviking_module, "_validate_openviking_reachability", lambda endpoint: (True, "")) - monkeypatch.setattr(openviking_module, "_validate_openviking_auth", lambda values: (True, "")) - - root_calls = [] - - def validate_root(values): - root_calls.append(dict(values)) - return True, "" - - monkeypatch.setattr(openviking_module, "_validate_openviking_root_access", validate_root) - - from hermes_cli import memory_setup - - monkeypatch.setattr( - memory_setup, - "_prompt", - _prompt_from_values({ - "OpenViking server URL": "https://openviking.example", - "OpenViking root API key": "root-secret", - "OpenViking account": "acct", - "OpenViking user": "alice", - "OpenViking agent": "agent", - }), - ) - choices = iter([2, 1, 1]) - monkeypatch.setattr( - memory_setup, - "_curses_select", - lambda *args, **kwargs: next(choices), - ) - config = {"memory": {}} - - OpenVikingMemoryProvider().post_setup(str(hermes_home), config) - - assert [call["api_key"] for call in root_calls] == ["root-secret"] - assert config["memory"]["provider"] == "openviking" - - -def test_post_setup_manual_retries_root_key_before_account_prompts(tmp_path, monkeypatch): - _clear_openviking_env(monkeypatch) - hermes_home = tmp_path / "hermes" - hermes_home.mkdir() - ovcli_path = tmp_path / "ovcli.conf" - ovcli_path.write_text(json.dumps({"url": "http://old.local"}), encoding="utf-8") - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path)) - monkeypatch.setattr(openviking_module, "_validate_openviking_reachability", lambda endpoint: (True, "")) - monkeypatch.setattr(openviking_module, "_validate_openviking_auth", lambda values: (True, "")) - - def validate_root(values): - if values["api_key"] == "bad-root": - return False, "OpenViking root API key validation failed: bad key" - return True, "" - - monkeypatch.setattr(openviking_module, "_validate_openviking_root_access", validate_root) - - from hermes_cli import memory_setup - - prompt_events = [] - prompts = { - "OpenViking server URL": iter(["https://openviking.example"]), - "OpenViking root API key": iter(["bad-root", "good-root"]), - "OpenViking account": iter(["acct"]), - "OpenViking user": iter(["alice"]), - "OpenViking agent": iter(["agent"]), - } - - def fake_prompt(label, default=None, secret=False): - prompt_events.append(label) - return next(prompts[label]) - - choices = iter([2, 1, 0, 1, 1]) - monkeypatch.setattr( - memory_setup, - "_curses_select", - lambda *args, **kwargs: next(choices), - ) - monkeypatch.setattr(memory_setup, "_prompt", fake_prompt) - config = {"memory": {}} - - OpenVikingMemoryProvider().post_setup(str(hermes_home), config) - - assert prompt_events.index("OpenViking account") > prompt_events.index("OpenViking root API key") - assert prompt_events.count("OpenViking account") == 1 - env_text = (hermes_home / ".env").read_text(encoding="utf-8") - assert "OPENVIKING_API_KEY=good-root" in env_text - - -def test_post_setup_manual_remote_requires_api_key(tmp_path, monkeypatch): - _clear_openviking_env(monkeypatch) - hermes_home = tmp_path / "hermes" - hermes_home.mkdir() - ovcli_path = tmp_path / "ovcli.conf" - original_ovcli = json.dumps({"url": "http://old.local"}) - ovcli_path.write_text(original_ovcli, encoding="utf-8") - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path)) 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([2, 0, 1]) - monkeypatch.setattr( - memory_setup, - "_curses_select", - lambda *args, **kwargs: next(choices), - ) + choices = iter([1, 0, 1]) + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(choices)) monkeypatch.setattr( memory_setup, "_prompt", @@ -659,219 +501,504 @@ def test_post_setup_manual_remote_requires_api_key(tmp_path, monkeypatch): save_config.assert_not_called() assert config == {"memory": {"provider": "builtin"}} - assert ovcli_path.read_text(encoding="utf-8") == original_ovcli assert not (hermes_home / ".env").exists() -def test_post_setup_manual_root_requires_account_and_user(tmp_path, monkeypatch): +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() - ovcli_path = tmp_path / "ovcli.conf" - original_ovcli = json.dumps({"url": "http://old.local"}) - ovcli_path.write_text(original_ovcli, encoding="utf-8") monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path)) - _allow_setup_validation(monkeypatch, root_access=True) - from hermes_cli import config as hermes_config from hermes_cli import memory_setup - save_config = MagicMock() - choices = iter([2, 1, 1]) - monkeypatch.setattr(hermes_config, "save_config", save_config) - monkeypatch.setattr( - memory_setup, - "_curses_select", - lambda *args, **kwargs: next(choices), - ) - monkeypatch.setattr( - memory_setup, - "_prompt", - _prompt_from_values({ + 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 root API key": "root-secret", - "OpenViking account": "", + "OpenViking user API key": "root-secret", + "OpenViking account": "acct", "OpenViking user": "alice", - }), - ) - config = {"memory": {"provider": "builtin"}} + "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) - save_config.assert_not_called() - assert config == {"memory": {"provider": "builtin"}} - assert ovcli_path.read_text(encoding="utf-8") == original_ovcli - assert not (hermes_home / ".env").exists() + 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_manual_local_allows_blank_api_key(tmp_path, monkeypatch): +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() - ovcli_path = tmp_path / "ovcli.conf" - original_ovcli = json.dumps({"url": "http://old.local"}) - ovcli_path.write_text(original_ovcli, 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([2, 0, 1]) - monkeypatch.setattr( - memory_setup, - "_curses_select", - lambda *args, **kwargs: next(choices), - ) + 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": "http://localhost:1933", + "OpenViking server URL": "https://openviking.example", + "OpenViking root API key": "user-secret", "OpenViking agent": "agent", }, - forbidden={ - "OpenViking account", - "OpenViking root API key", - "OpenViking user", - "OpenViking user API key", - }, + forbidden={"OpenViking user API key", "OpenViking account", "OpenViking user"}, ), ) config = {"memory": {}} OpenVikingMemoryProvider().post_setup(str(hermes_home), config) - assert config["memory"]["provider"] == "openviking" - assert config["memory"]["openviking"]["use_ovcli_config"] is False - assert ovcli_path.read_text(encoding="utf-8") == original_ovcli env_text = (hermes_home / ".env").read_text(encoding="utf-8") - assert "OPENVIKING_ENDPOINT=http://localhost:1933" in env_text + assert "OPENVIKING_API_KEY=user-secret" in env_text assert "OPENVIKING_AGENT=agent" in env_text - assert "OPENVIKING_API_KEY" not in env_text assert "OPENVIKING_ACCOUNT" not in env_text assert "OPENVIKING_USER" not in env_text -def test_post_setup_cancel_existing_ovcli_writes_nothing(tmp_path, monkeypatch): +def test_manual_root_key_flow_prints_validation_progress(monkeypatch, capsys): _clear_openviking_env(monkeypatch) - hermes_home = tmp_path / "hermes" - hermes_home.mkdir() - env_path = hermes_home / ".env" - original_env = "OPENVIKING_ENDPOINT=http://old.local\nOTHER_KEY=keep\n" - env_path.write_text(original_env, encoding="utf-8") - ovcli_path = tmp_path / "ovcli.conf" - ovcli_path.write_text(json.dumps({"url": "http://openviking.local"}), encoding="utf-8") - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path)) - from hermes_cli import config as hermes_config - from hermes_cli import memory_setup + monkeypatch.setattr(openviking_module, "_validate_openviking_reachability", lambda endpoint: (True, "")) - save_config = MagicMock() - monkeypatch.setattr(hermes_config, "save_config", save_config) - monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: -1) - config = {"memory": {"provider": "builtin"}} + validate_calls = [] - OpenVikingMemoryProvider().post_setup(str(hermes_home), config) + def validate_values(values, *, require_api_key=False): + validate_calls.append(dict(values)) + return True, "", "root" - save_config.assert_not_called() - assert config == {"memory": {"provider": "builtin"}} - assert env_path.read_text(encoding="utf-8") == original_env + monkeypatch.setattr(openviking_module, "_validate_openviking_setup_values", validate_values) + choices = iter([1]) - -def test_post_setup_invalid_existing_ovcli_writes_nothing(tmp_path, monkeypatch): - _clear_openviking_env(monkeypatch) - hermes_home = tmp_path / "hermes" - hermes_home.mkdir() - env_path = hermes_home / ".env" - original_env = "OPENVIKING_ENDPOINT=http://old.local\nOTHER_KEY=keep\n" - env_path.write_text(original_env, encoding="utf-8") - ovcli_path = tmp_path / "ovcli.conf" - ovcli_path.write_text("{", encoding="utf-8") - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path)) - - 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) - monkeypatch.setattr( - memory_setup, - "_curses_select", - MagicMock(side_effect=AssertionError("picker should not open for invalid ovcli.conf")), + 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, ) - config = {"memory": {"provider": "builtin"}} - OpenVikingMemoryProvider().post_setup(str(hermes_home), config) - - save_config.assert_not_called() - assert config == {"memory": {"provider": "builtin"}} - assert env_path.read_text(encoding="utf-8") == original_env + 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_post_setup_creates_minimal_ovcli_and_links(tmp_path, monkeypatch): +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_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_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() - ovcli_path = tmp_path / "missing" / "ovcli.conf" monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path)) + monkeypatch.setattr(openviking_module, "_validate_openviking_setup_values", lambda values, *, require_api_key=False: (True, "", None)) from hermes_cli import memory_setup - monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: 0) + 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", - lambda label, default=None, secret=False: default or "", + _prompt_from_values({ + "OpenViking server URL": "localhost", + "OpenViking agent": "agent", + }), ) config = {"memory": {}} OpenVikingMemoryProvider().post_setup(str(hermes_home), config) - assert config["memory"]["provider"] == "openviking" - assert config["memory"]["openviking"]["use_ovcli_config"] is True - data = json.loads(ovcli_path.read_text(encoding="utf-8")) - assert data == { - "url": "http://127.0.0.1:1933", - "agent_id": "hermes", - } - env_path = hermes_home / ".env" - if env_path.exists(): - assert env_path.read_text(encoding="utf-8") == "" + 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_cancel_missing_ovcli_does_not_prompt_or_create(tmp_path, monkeypatch): +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 / "missing" / "ovcli.conf" + 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 config as hermes_config from hermes_cli import memory_setup - save_config = MagicMock() - monkeypatch.setattr(hermes_config, "save_config", save_config) - monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: -1) + choices = iter([1, 0, 0]) + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(choices)) monkeypatch.setattr( memory_setup, "_prompt", - MagicMock(side_effect=AssertionError("prompts should not run after cancel")), + _prompt_from_values({ + "OpenViking server URL": "https://openviking.example", + "OpenViking user API key": "user-secret", + "OpenViking agent": "agent", + }), ) - config = {"memory": {"provider": "builtin"}} + config = {"memory": {}} OpenVikingMemoryProvider().post_setup(str(hermes_home), config) - save_config.assert_not_called() - assert config == {"memory": {"provider": "builtin"}} - assert not ovcli_path.exists() - assert not (hermes_home / ".env").exists() + 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(): @@ -1181,6 +1308,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 @@ -1205,6 +1333,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 "