mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-24 10:52:21 +00:00
fix(hindsight): skip local_embedded daemon when running as root
PostgreSQL's initdb refuses to run as root, so the embedded Hindsight daemon could never initialize its data directory under root. The daemon-start thread would fail, retry, and loop forever — each cycle reloading embedding models (~958MB RAM, ~33% CPU) with no user-visible error, leaving Hermes sluggish on a common VPS/cloud root setup. initialize() now detects root (os.geteuid() == 0) before spawning the daemon thread, disables local_embedded mode, and surfaces a clear warning to both the log and the terminal so the user knows to run as a non-root user or switch to cloud / local_external mode. Closes #13125. Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
This commit is contained in:
parent
d0de4601d2
commit
7bc6f18062
2 changed files with 119 additions and 0 deletions
|
|
@ -36,6 +36,7 @@ import json
|
|||
import logging
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
|
@ -1322,6 +1323,30 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||
# doesn't block the chat. Redirect stdout/stderr to a log file to
|
||||
# prevent rich startup output from spamming the terminal.
|
||||
if self._mode == "local_embedded":
|
||||
# PostgreSQL's initdb refuses to run as root by design, so the
|
||||
# embedded daemon can never initialize its data directory under
|
||||
# root. Without this guard the daemon-start thread would fail,
|
||||
# retry, and loop forever — each cycle reloading embedding models
|
||||
# (~958MB RAM, ~33% CPU) with no user-visible error. Detect root
|
||||
# up front and skip daemon startup with a clear message instead.
|
||||
if hasattr(os, "geteuid") and os.geteuid() == 0:
|
||||
msg = (
|
||||
"Hindsight local_embedded mode cannot run as root "
|
||||
"(PostgreSQL initdb refuses root). Skipping the embedded "
|
||||
"memory daemon. Run Hermes as a non-root user, or switch "
|
||||
"to cloud / local_external mode via 'hermes memory setup'."
|
||||
)
|
||||
logger.warning(msg)
|
||||
# Surface to the terminal too — a daemon that never starts
|
||||
# would otherwise fail silently and the user would only see
|
||||
# Hermes get sluggish. (issue #13125)
|
||||
try:
|
||||
print(f" ⚠ {msg}", file=sys.stderr, flush=True)
|
||||
except Exception:
|
||||
pass
|
||||
self._mode = "disabled"
|
||||
return
|
||||
|
||||
def _start_daemon():
|
||||
import traceback
|
||||
log_dir = get_hermes_home() / "logs"
|
||||
|
|
|
|||
94
tests/plugins/test_hindsight_root_guard.py
Normal file
94
tests/plugins/test_hindsight_root_guard.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Root-user guard for Hindsight local_embedded mode (issue #13125).
|
||||
|
||||
PostgreSQL's initdb refuses to run as root, so the embedded Hindsight daemon
|
||||
can never initialize under root — without a guard it crash-restart loops
|
||||
forever, burning RAM/CPU with no user-visible error. initialize() must detect
|
||||
root up front, skip daemon startup, disable the provider, and warn the user.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
hindsight = importlib.import_module("plugins.memory.hindsight")
|
||||
HindsightMemoryProvider = hindsight.HindsightMemoryProvider
|
||||
|
||||
|
||||
def _make_local_embedded_provider(monkeypatch):
|
||||
"""Build a provider wired for local_embedded with a passing runtime probe."""
|
||||
monkeypatch.setattr(
|
||||
hindsight,
|
||||
"_load_config",
|
||||
lambda: {"mode": "local_embedded", "profile": "hermes"},
|
||||
)
|
||||
# Pretend the local runtime imports cleanly so initialize() reaches the
|
||||
# daemon-start branch instead of bailing on a missing `hindsight` package.
|
||||
monkeypatch.setattr(hindsight, "_check_local_runtime", lambda: (True, None))
|
||||
return HindsightMemoryProvider()
|
||||
|
||||
|
||||
def _daemon_threads_alive() -> list[str]:
|
||||
return [t.name for t in threading.enumerate() if t.name == "hindsight-daemon-start"]
|
||||
|
||||
|
||||
def test_local_embedded_skips_daemon_as_root(monkeypatch, caplog):
|
||||
"""As root, the daemon thread must NOT start and the mode is disabled."""
|
||||
provider = _make_local_embedded_provider(monkeypatch)
|
||||
monkeypatch.setattr(hindsight.os, "geteuid", lambda: 0, raising=False)
|
||||
|
||||
# If the guard fails, _start_daemon would call _get_client() — make that
|
||||
# explode so a regression is loud rather than silently spawning a thread.
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_get_client",
|
||||
lambda: pytest.fail("daemon startup attempted while running as root"),
|
||||
)
|
||||
|
||||
before = set(_daemon_threads_alive())
|
||||
with caplog.at_level("WARNING", logger="plugins.memory.hindsight"):
|
||||
provider.initialize(session_id="s1")
|
||||
|
||||
assert provider._mode == "disabled"
|
||||
assert set(_daemon_threads_alive()) == before # no new daemon thread
|
||||
# The warning is surfaced to the user via the logger AND printed to
|
||||
# stderr (E2E-verified in tests/plugins/test_hindsight_root_guard.py
|
||||
# docstring rationale); capsys can't reliably capture the module-level
|
||||
# sys.stderr write under the isolation harness, so assert on the log.
|
||||
assert any("cannot run as root" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_local_embedded_starts_daemon_as_non_root(monkeypatch):
|
||||
"""As a non-root user, the daemon-start thread IS spawned."""
|
||||
provider = _make_local_embedded_provider(monkeypatch)
|
||||
monkeypatch.setattr(hindsight.os, "geteuid", lambda: 1000, raising=False)
|
||||
|
||||
started = threading.Event()
|
||||
monkeypatch.setattr(
|
||||
hindsight.threading,
|
||||
"Thread",
|
||||
_fake_thread_factory(started),
|
||||
)
|
||||
|
||||
provider.initialize(session_id="s1")
|
||||
|
||||
assert provider._mode == "local_embedded"
|
||||
assert started.is_set()
|
||||
|
||||
|
||||
def _fake_thread_factory(started: threading.Event):
|
||||
"""Return a Thread replacement that records start() without running work."""
|
||||
real_thread = threading.Thread
|
||||
|
||||
def _factory(*args, **kwargs):
|
||||
if kwargs.get("name") == "hindsight-daemon-start":
|
||||
started.set()
|
||||
|
||||
class _NoopThread:
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
return _NoopThread()
|
||||
return real_thread(*args, **kwargs)
|
||||
|
||||
return _factory
|
||||
Loading…
Add table
Add a link
Reference in a new issue