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

@ -516,12 +516,17 @@ def _ensure_sdk_installed() -> bool:
return False
print(" Installing honcho-ai...", flush=True)
from hermes_cli.tools_config import _pip_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
result = _pip_install(["honcho-ai==2.2.0"])
if result.returncode == 0:
result = install_specs(["honcho-ai==2.2.0"])
if result.ok:
print(" Installed.\n")
return True
elif result.blocked:
print(f" Cannot install: {result.reason}\n")
return False
else:
print(f" Install failed:\n{(result.stderr or '').strip()}")
print(" Run manually: uv pip install 'honcho-ai==2.2.0'\n")

View file

@ -863,11 +863,17 @@ def _install_provider_deps(llm_id: str, embedder_id: str, vector_id: str) -> Non
for dep in sorted(deps):
try:
print(f" Installing {dep}...")
subprocess.run(
["uv", "pip", "install", "--python", sys.executable, dep],
capture_output=True, timeout=60,
)
print(f" ✓ Installed {dep}")
# 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([dep], timeout=60)
if outcome.ok:
print(f" ✓ Installed {dep}")
elif outcome.blocked:
print(f" Warning: cannot install {dep}: {outcome.reason}")
else:
print(f" Warning: Could not install {dep}. Install manually: uv pip install {dep}")
except Exception:
print(f" Warning: Could not install {dep}. Install manually: uv pip install {dep}")
if deps: