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 "