fix: route memory-provider dep installs through lazy_deps durable target

Installing a memory provider (Honcho, mem0, hindsight, ...) from the
dashboard Plugins page failed on hosted deployments with a permission
error: the setup endpoint shelled out to
`uv pip install --python sys.executable`, which targets the sealed
read-only venv under /opt/hermes (immutable hosted image, NS-579/#49113).

The correct mechanism already exists: tools/lazy_deps.py redirects
installs to the writable durable target on the data volume
(HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages) when the venv is
sealed (HERMES_DISABLE_LAZY_INSTALLS=1), appends the target to the END
of sys.path (core venv always wins collisions), and constrains shared
deps to core-venv versions. The dashboard installer simply never used
it.

Fix:
- tools/lazy_deps.py: new public install_specs() — installs arbitrary
  manifest-declared pip specs through the same environment routing as
  ensure(): venv-scoped by default, durable-target on sealed images,
  refused with an actionable reason when gated off (config kill switch
  or sealed venv without a target — never surfaces raw EROFS/EACCES).
  Specs are validated with _spec_is_safe(); post-install it invalidates
  import/metadata caches so availability rechecks in the same process
  see the new packages without a restart. Never raises.
- hermes_cli/web_server.py: _install_memory_provider_pip_dependencies
  now calls install_specs() instead of building its own uv/pip
  subprocess. Blocked installs surface the gate reason in the setup
  results; the response's status block reflects post-install
  availability (stale 'missing deps' state clears immediately).
- hermes_cli/memory_setup.py, plugins/memory/honcho/cli.py,
  plugins/memory/mem0/_setup.py: CLI setup wizards routed through
  install_specs() too — same sealed-venv failure mode, same fix.

No hosted setup path writes to /opt/hermes anymore; provider discovery
and installation now use the same environment (sys.path activation is
shared with the lazy-install bootstrap in hermes_bootstrap).

Tests:
- tests/tools/test_lazy_deps.py: TestInstallSpecs — gating matrix
  (sealed+no-target blocked with immutable-deployment reason, config
  kill switch, sealed+target proceeds), spec-safety rejection before
  any subprocess, venv-scoped vs --target command display, failure
  stderr passthrough, never-raises contract.
- tests/hermes_cli/test_web_server.py: setup endpoint routes pip
  through lazy_deps (regression guard asserts no direct 'pip install'
  subprocess), blocked-reason surfacing, same-response availability
  recheck clears stale missing state.

Fixes NS-605 (Plain T-1111).
This commit is contained in:
Shannon Sands 2026-07-27 10:32:21 +10:00 • committed by Teknium
parent 2796fca8c9
commit 8bbd77f368
7 changed files with 416 additions and 25 deletions

View file

@ -162,16 +162,22 @@ def _install_dependencies(provider_name: str, *, force: bool = False) -> None:
print(f"\n Installing dependencies: {', '.join(missing)}")
from hermes_cli.tools_config import _pip_install
# Environment-aware install: on immutable hosted images the agent venv
# is sealed read-only and installs must go to the durable target on the
# data volume (HERMES_LAZY_INSTALL_TARGET). install_specs handles the
# routing/gating; on normal installs it is venv-scoped as before (NS-605).
from tools.lazy_deps import install_specs
manual_cmd = f"uv pip install {' '.join(missing)}"
try:
result = _pip_install(["--quiet"] + missing, timeout=120)
if result.returncode == 0:
outcome = install_specs(missing, timeout=120)
if outcome.ok:
print(f" ✓ Installed {', '.join(missing)}")
elif outcome.blocked:
print(f" ⚠ Cannot install {', '.join(missing)}: {outcome.reason}")
else:
print(f" ⚠ Failed to install {', '.join(missing)}")
stderr = (result.stderr or "")[:200]
stderr = (outcome.stderr or "")[:200]
if stderr:
print(f" {stderr}")
print(f" Run manually: {manual_cmd}")