mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
fix(hindsight): route setup + auto-upgrade installs through lazy_deps
Widen NS-605 to the two remaining direct-install sites in the hindsight plugin, which still shelled out to 'uv pip install --python sys.executable' and therefore failed (EROFS/EACCES) on immutable hosted images with sealed venvs, and lost packages on redeploy: - post_setup dependency install (~L835): install_specs() with ok / blocked-reason / stderr handling matching honcho/mem0. - initialize()-time hindsight-client auto-upgrade (~L1240): install_specs(); blocked installs log the gate reason with the manual command instead of a raw subprocess error, and init proceeds. Audited every other memory plugin (supermemory, byterover, holographic, openviking, retaindb) for direct pip/uv install subprocess calls: none remain — their deps flow through plugin.yaml pip_dependencies or lazy_deps.ensure(). Tests: TestClientAutoUpgradeRoutesThroughLazyDeps — upgrade goes through install_specs with the exact spec (regression guard asserts no subprocess.run), blocked upgrade is non-fatal and surfaces the gate reason. Updated TestPostSetupEnvEncoding stubs to the new install path.
This commit is contained in:
parent
8bbd77f368
commit
0227872bf5
2 changed files with 90 additions and 33 deletions
|
|
@ -833,21 +833,18 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||
provider_config["llm_provider"] = llm_provider
|
||||
|
||||
print("\n Checking dependencies...")
|
||||
uv_path = shutil.which("uv")
|
||||
if not uv_path:
|
||||
print(" ⚠ uv not found — install it: curl -LsSf https://astral.sh/uv/install.sh | sh")
|
||||
print(f" Then run manually: uv pip install --python {sys.executable} {' '.join(deps_to_install)}")
|
||||
# Environment-aware install: sealed hosted venvs redirect to the durable
|
||||
# data-volume target instead of writing to /opt/hermes (NS-605).
|
||||
from tools.lazy_deps import install_specs
|
||||
|
||||
outcome = install_specs(deps_to_install, timeout=120)
|
||||
if outcome.ok:
|
||||
print(" ✓ Dependencies up to date")
|
||||
elif outcome.blocked:
|
||||
print(f" ⚠ Cannot install dependencies: {outcome.reason}")
|
||||
else:
|
||||
try:
|
||||
subprocess.run(
|
||||
[uv_path, "pip", "install", "--python", sys.executable, "--quiet", "--upgrade"] + deps_to_install,
|
||||
check=True, timeout=120, capture_output=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
print(" ✓ Dependencies up to date")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Install failed: {e}")
|
||||
print(f" Run manually: uv pip install --python {sys.executable} {' '.join(deps_to_install)}")
|
||||
print(f" ⚠ Install failed:\n{(outcome.stderr or '').strip()}")
|
||||
print(f" Run manually: uv pip install --python {sys.executable} {' '.join(deps_to_install)}")
|
||||
|
||||
# Step 3: Mode-specific config
|
||||
if mode == "cloud":
|
||||
|
|
@ -1234,24 +1231,18 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||
if Version(installed) < Version(_MIN_CLIENT_VERSION):
|
||||
logger.warning("hindsight-client %s is outdated (need >=%s), attempting upgrade...",
|
||||
installed, _MIN_CLIENT_VERSION)
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
uv_path = shutil.which("uv")
|
||||
if uv_path:
|
||||
try:
|
||||
subprocess.run(
|
||||
[uv_path, "pip", "install", "--python", sys.executable,
|
||||
"--quiet", "--upgrade", f"hindsight-client>={_MIN_CLIENT_VERSION}"],
|
||||
check=True, timeout=120, capture_output=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
logger.info("hindsight-client upgraded to >=%s", _MIN_CLIENT_VERSION)
|
||||
except Exception as e:
|
||||
logger.warning("Auto-upgrade failed: %s. Run: uv pip install 'hindsight-client>=%s'",
|
||||
e, _MIN_CLIENT_VERSION)
|
||||
# Environment-aware install: sealed hosted venvs redirect to the
|
||||
# durable data-volume target instead of /opt/hermes (NS-605).
|
||||
from tools.lazy_deps import install_specs
|
||||
outcome = install_specs([f"hindsight-client>={_MIN_CLIENT_VERSION}"], timeout=120)
|
||||
if outcome.ok:
|
||||
logger.info("hindsight-client upgraded to >=%s", _MIN_CLIENT_VERSION)
|
||||
elif outcome.blocked:
|
||||
logger.warning("Auto-upgrade unavailable: %s. Run: uv pip install 'hindsight-client>=%s'",
|
||||
outcome.reason, _MIN_CLIENT_VERSION)
|
||||
else:
|
||||
logger.warning("uv not found. Run: pip install 'hindsight-client>=%s'", _MIN_CLIENT_VERSION)
|
||||
logger.warning("Auto-upgrade failed: %s. Run: uv pip install 'hindsight-client>=%s'",
|
||||
(outcome.stderr or "").strip() or "install error", _MIN_CLIENT_VERSION)
|
||||
except Exception:
|
||||
pass # packaging not available or other issue — proceed anyway
|
||||
|
||||
|
|
|
|||
|
|
@ -1842,12 +1842,16 @@ class TestPostSetupEnvEncoding:
|
|||
def _run_cloud_post_setup(self, tmp_path, monkeypatch):
|
||||
"""Drive post_setup through the cloud path with piped stdin."""
|
||||
import io
|
||||
import shutil as shutil_mod
|
||||
|
||||
monkeypatch.setattr("hermes_cli.memory_setup._curses_select",
|
||||
lambda *a, **kw: 0) # cloud mode
|
||||
monkeypatch.setattr("hermes_cli.config.save_config", lambda c: None)
|
||||
monkeypatch.setattr(shutil_mod, "which", lambda *_: None) # skip uv install
|
||||
# Skip the dependency install (now routed through lazy_deps, NS-605).
|
||||
import tools.lazy_deps as lazy_deps_mod
|
||||
monkeypatch.setattr(
|
||||
lazy_deps_mod, "install_specs",
|
||||
lambda *a, **kw: lazy_deps_mod.InstallSpecsResult(ok=True),
|
||||
)
|
||||
# First line: API key prompt (readline). Second line: API URL (input).
|
||||
monkeypatch.setattr(sys, "stdin", io.StringIO("sk-new\n\n"))
|
||||
|
||||
|
|
@ -1879,3 +1883,65 @@ class TestPostSetupEnvEncoding:
|
|||
content = env_path.read_text(encoding="utf-8")
|
||||
assert "PROXY_NOTE=café-zürich-完了" in content
|
||||
assert "HINDSIGHT_API_KEY=sk-new" in content
|
||||
|
||||
|
||||
class TestClientAutoUpgradeRoutesThroughLazyDeps:
|
||||
"""The initialize()-time hindsight-client auto-upgrade must go through
|
||||
lazy_deps.install_specs() (environment-aware, durable-target on sealed
|
||||
hosted venvs) — never a direct `uv pip install --python sys.executable`
|
||||
subprocess, which fails with EROFS/EACCES on immutable images (NS-605)."""
|
||||
|
||||
def _init_with_outdated_client(self, tmp_path, monkeypatch, outcome):
|
||||
import importlib.metadata as md
|
||||
import subprocess as subprocess_mod
|
||||
import tools.lazy_deps as lazy_deps_mod
|
||||
|
||||
config_path = tmp_path / "hindsight" / "config.json"
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(json.dumps({"mode": "cloud"}))
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory.hindsight.get_hermes_home", lambda: tmp_path
|
||||
)
|
||||
|
||||
# Simulate an installed-but-outdated client.
|
||||
monkeypatch.setattr(md, "version", lambda name: "0.0.1")
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
lazy_deps_mod, "install_specs",
|
||||
lambda specs, **kw: calls.append(tuple(specs)) or outcome,
|
||||
)
|
||||
|
||||
# Regression guard: no direct pip subprocess may run.
|
||||
def _no_subprocess(*a, **kw): # pragma: no cover - fails loudly
|
||||
raise AssertionError(f"unexpected subprocess.run during auto-upgrade: {a}")
|
||||
monkeypatch.setattr(subprocess_mod, "run", _no_subprocess)
|
||||
|
||||
provider = HindsightMemoryProvider()
|
||||
provider.initialize(session_id="s", hermes_home=str(tmp_path), platform="cli")
|
||||
return calls
|
||||
|
||||
def test_upgrade_uses_install_specs_not_subprocess(self, tmp_path, monkeypatch):
|
||||
from plugins.memory.hindsight import _MIN_CLIENT_VERSION
|
||||
from tools.lazy_deps import InstallSpecsResult
|
||||
|
||||
calls = self._init_with_outdated_client(
|
||||
tmp_path, monkeypatch, InstallSpecsResult(ok=True)
|
||||
)
|
||||
assert calls == [(f"hindsight-client>={_MIN_CLIENT_VERSION}",)]
|
||||
|
||||
def test_blocked_upgrade_is_nonfatal_and_surfaces_reason(
|
||||
self, tmp_path, monkeypatch, caplog
|
||||
):
|
||||
import logging
|
||||
from tools.lazy_deps import InstallSpecsResult
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
calls = self._init_with_outdated_client(
|
||||
tmp_path, monkeypatch,
|
||||
InstallSpecsResult(ok=False, blocked=True,
|
||||
reason="runtime installs are disabled on this deployment"),
|
||||
)
|
||||
assert len(calls) == 1 # attempted exactly once, init still completed
|
||||
assert any("runtime installs are disabled" in r.getMessage()
|
||||
for r in caplog.records)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue