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:
Teknium 2026-07-28 21:55:35 -07:00
parent 8bbd77f368
commit 0227872bf5
2 changed files with 90 additions and 33 deletions

View file

@ -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

View file

@ -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)