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}")

View file

@ -5967,34 +5967,53 @@ def _install_memory_provider_pip_dependencies(dependencies: List[str]) -> List[D
_command_result(kind="pip", name=", ".join(dependencies), status="already_installed")
]
uv_path = shutil.which("uv")
if uv_path:
command: Any = [uv_path, "pip", "install", "--python", sys.executable, "--quiet", *missing]
display = f"uv pip install --python {sys.executable} {' '.join(missing)}"
else:
command = [sys.executable, "-m", "pip", "install", "--quiet", *missing]
display = f"{sys.executable} -m pip install {' '.join(missing)}"
# Route through the lazy-install pipeline (tools.lazy_deps.install_specs)
# instead of shelling out to pip against sys.executable directly. That
# pipeline is environment-aware: on hosted/immutable images the agent venv
# under /opt/hermes is sealed read-only, and installs must be redirected
# to the writable durable target on the data volume
# (HERMES_LAZY_INSTALL_TARGET, e.g. /opt/data/lazy-packages) — the same
# path every lazy backend already uses. A direct `pip install --python
# sys.executable` on those images fails with a permission error (NS-605).
# install_specs also activates the target on sys.path post-install so the
# availability recheck below sees the new packages without a restart.
try:
completed = _run_setup_command(command, display=display, timeout=240)
from tools.lazy_deps import install_specs
outcome = install_specs(missing, timeout=240)
except Exception as exc:
return [
_command_result(
kind="pip",
name=", ".join(missing),
status="failed",
command=display,
error=str(exc),
)
]
if outcome.blocked:
return [
_command_result(
kind="pip",
name=", ".join(missing),
status="failed",
command=outcome.command,
error=outcome.reason,
)
]
return [
_command_result(
kind="pip",
name=", ".join(missing),
status="installed" if completed.returncode == 0 else "failed",
command=display,
completed=completed,
status="installed" if outcome.ok else "failed",
command=outcome.command,
completed=subprocess.CompletedProcess(
args=outcome.command,
returncode=0 if outcome.ok else 1,
stdout=outcome.stdout,
stderr=outcome.stderr,
),
)
]

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:

View file

@ -833,6 +833,107 @@ class TestWebServerEndpoints:
]
assert calls[-1][0] == ["brv", "--version"]
def test_post_memory_provider_setup_routes_pip_through_lazy_deps(self, monkeypatch):
"""NS-605: dashboard pip installs must use the environment-aware
lazy_deps pipeline (durable-target redirect on immutable hosted
images), never a direct `pip install --python sys.executable`."""
import subprocess as _subprocess
import hermes_cli.web_server as web_server
from tools import lazy_deps as ld
# honcho declares pip_dependencies: [honcho-ai]; force it missing.
monkeypatch.setattr(web_server, "_dependency_importable", lambda dep: False)
installed = []
def fake_install_specs(specs, *, timeout=300):
installed.append(tuple(specs))
return ld.InstallSpecsResult(
ok=True, command="uv pip install --target /opt/data/lazy-packages honcho-ai",
stdout="ok", stderr="",
)
monkeypatch.setattr(ld, "install_specs", fake_install_specs)
# Any direct pip/uv subprocess from the memory-provider pip path is
# a regression; external-dep checks may still run subprocess, so only
# trip on pip-flavored commands.
real_run = _subprocess.run
def guarded_run(command, **kwargs):
flat = command if isinstance(command, str) else " ".join(map(str, command))
assert "pip install" not in flat, f"direct pip call leaked: {flat}"
return real_run(command, **kwargs)
monkeypatch.setattr(web_server.subprocess, "run", guarded_run)
resp = self.client.post("/api/memory/providers/honcho/setup", json={"values": {}})
assert resp.status_code == 200
data = resp.json()
pip_rows = [row for row in data["results"] if row["kind"] == "pip"]
assert pip_rows and pip_rows[0]["status"] == "installed"
assert "--target /opt/data/lazy-packages" in pip_rows[0]["command"]
assert installed == [("honcho-ai",)]
def test_post_memory_provider_setup_reports_blocked_install_reason(self, monkeypatch):
"""When installs are gated off (sealed venv, no durable target),
the dashboard surfaces the actionable reason instead of a raw
permission error."""
import hermes_cli.web_server as web_server
from tools import lazy_deps as ld
monkeypatch.setattr(web_server, "_dependency_importable", lambda dep: False)
monkeypatch.setattr(
ld, "install_specs",
lambda specs, *, timeout=300: ld.InstallSpecsResult(
ok=False, blocked=True,
reason=(
"runtime installs are disabled on this deployment: the "
"agent environment is immutable and no writable install "
"target is configured (HERMES_LAZY_INSTALL_TARGET)"
),
),
)
resp = self.client.post("/api/memory/providers/honcho/setup", json={"values": {}})
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is False
pip_rows = [row for row in data["results"] if row["kind"] == "pip"]
assert pip_rows and pip_rows[0]["status"] == "failed"
assert "immutable" in pip_rows[0]["stderr"]
def test_post_memory_provider_setup_recheck_clears_stale_missing_state(self, monkeypatch):
"""After a successful install, the same response's status block must
reflect the new availability (no restart, no stale 'missing deps')."""
import hermes_cli.web_server as web_server
from tools import lazy_deps as ld
installed_now = {"flag": False}
def fake_importable(dep):
return installed_now["flag"]
monkeypatch.setattr(web_server, "_dependency_importable", fake_importable)
def fake_install_specs(specs, *, timeout=300):
installed_now["flag"] = True # install makes the package importable
return ld.InstallSpecsResult(ok=True, command="uv pip install honcho-ai")
monkeypatch.setattr(ld, "install_specs", fake_install_specs)
resp = self.client.post("/api/memory/providers/honcho/setup", json={"values": {}})
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
status = data["status"]
assert status is not None
assert status["setup"]["dependencies_installed"] is True
def test_post_unknown_memory_provider_setup_returns_404(self):
resp = self.client.post("/api/memory/providers/nope/setup", json={"values": {}})

View file

@ -472,3 +472,158 @@ class TestRefreshActiveFeatures:
result = ld.refresh_active_features()
assert result["a.ok"] == "current"
assert result["b.fail"].startswith("failed:")
# ---------------------------------------------------------------------------
# install_specs — manifest-driven installs (dashboard memory providers etc.)
#
# NS-605: the dashboard's memory-provider setup endpoint used to shell out
# to `uv pip install --python sys.executable`, which fails with a permission
# error on the sealed hosted venv. install_specs routes those installs
# through the same environment-aware pipeline as ensure(): venv-scoped on
# normal installs, redirected to the durable target on immutable images,
# and cleanly refused (with a reason) when installs are gated off.
# ---------------------------------------------------------------------------
class TestInstallSpecs:
def test_empty_specs_is_trivially_ok(self, monkeypatch):
monkeypatch.setattr(
ld, "_venv_pip_install",
lambda *a, **kw: pytest.fail("pip should not be called"),
)
result = ld.install_specs([])
assert result.ok is True
assert result.blocked is False
def test_blank_specs_are_ignored(self, monkeypatch):
monkeypatch.setattr(
ld, "_venv_pip_install",
lambda *a, **kw: pytest.fail("pip should not be called"),
)
result = ld.install_specs(["", " "])
assert result.ok is True
@pytest.mark.parametrize("bad", [
"pkg; rm -rf /",
"-e git+https://evil.example/repo.git",
"https://evil.example/pkg.tar.gz",
"../../etc/passwd",
"pkg @ file:///tmp/x",
])
def test_unsafe_specs_are_blocked_before_any_install(self, monkeypatch, bad):
monkeypatch.setattr(
ld, "_venv_pip_install",
lambda *a, **kw: pytest.fail("pip should not be called"),
)
result = ld.install_specs([bad])
assert result.ok is False
assert result.blocked is True
assert "unsafe spec" in result.reason
def test_one_unsafe_spec_blocks_the_whole_batch(self, monkeypatch):
monkeypatch.setattr(
ld, "_venv_pip_install",
lambda *a, **kw: pytest.fail("pip should not be called"),
)
result = ld.install_specs(["honcho-ai==2.2.0", "pkg; rm -rf /"])
assert result.blocked is True
def test_sealed_venv_without_target_reports_immutable_reason(self, monkeypatch):
# HERMES_DISABLE_LAZY_INSTALLS=1 with no durable target: never touch
# pip, never surface EROFS — report an actionable reason instead.
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
monkeypatch.delenv(ld._LAZY_TARGET_ENV, raising=False)
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda: {}, raising=False
)
monkeypatch.setattr(
ld, "_venv_pip_install",
lambda *a, **kw: pytest.fail("pip should not be called"),
)
result = ld.install_specs(["honcho-ai==2.2.0"])
assert result.ok is False
assert result.blocked is True
assert "immutable" in result.reason
assert "HERMES_LAZY_INSTALL_TARGET" in result.reason
def test_config_killswitch_reports_config_reason(self, monkeypatch):
monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False)
monkeypatch.delenv(ld._LAZY_TARGET_ENV, raising=False)
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"security": {"allow_lazy_installs": False}},
raising=False,
)
monkeypatch.setattr(
ld, "_venv_pip_install",
lambda *a, **kw: pytest.fail("pip should not be called"),
)
result = ld.install_specs(["honcho-ai==2.2.0"])
assert result.blocked is True
assert "allow_lazy_installs" in result.reason
def test_sealed_venv_with_target_installs(self, monkeypatch, tmp_path):
# The hosted-image configuration: sealed venv + durable target.
# install_specs must proceed (the redirect is the safe path).
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
monkeypatch.setenv(ld._LAZY_TARGET_ENV, str(tmp_path / "lazy"))
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda: {}, raising=False
)
calls = []
monkeypatch.setattr(
ld, "_venv_pip_install",
lambda specs, **kw: (calls.append(specs), ld._InstallResult(True, "ok", ""))[1],
)
result = ld.install_specs(["honcho-ai==2.2.0"])
assert result.ok is True
assert result.blocked is False
assert calls == [("honcho-ai==2.2.0",)]
# Command display names the durable target so the dashboard shows
# where the install actually went.
assert str(tmp_path / "lazy") in result.command
def test_default_env_installs_venv_scoped(self, monkeypatch):
monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False)
monkeypatch.delenv(ld._LAZY_TARGET_ENV, raising=False)
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda: {}, raising=False
)
monkeypatch.setattr(
ld, "_venv_pip_install",
lambda specs, **kw: ld._InstallResult(True, "installed", ""),
)
result = ld.install_specs(["mem0ai>=2.0.10,<3"])
assert result.ok is True
assert "--target" not in result.command
def test_install_failure_surfaces_stderr(self, monkeypatch):
monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False)
monkeypatch.delenv(ld._LAZY_TARGET_ENV, raising=False)
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda: {}, raising=False
)
monkeypatch.setattr(
ld, "_venv_pip_install",
lambda specs, **kw: ld._InstallResult(False, "", "ERROR: resolution impossible"),
)
result = ld.install_specs(["honcho-ai==2.2.0"])
assert result.ok is False
assert result.blocked is False
assert "resolution impossible" in result.stderr
def test_never_raises_on_unexpected_error(self, monkeypatch):
monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False)
monkeypatch.delenv(ld._LAZY_TARGET_ENV, raising=False)
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda: {}, raising=False
)
# Contract: install_specs never raises — even an unexpected installer
# crash comes back as a failed result the caller can render.
def boom(specs, **kw):
raise RuntimeError("disk on fire")
monkeypatch.setattr(ld, "_venv_pip_install", boom)
result = ld.install_specs(["honcho-ai==2.2.0"])
assert result.ok is False
assert "disk on fire" in result.stderr

View file

@ -911,6 +911,105 @@ def feature_install_command(feature: str) -> Optional[str]:
return "uv pip install " + " ".join(repr(s) for s in specs)
@dataclass
class InstallSpecsResult:
"""Outcome of :func:`install_specs` for one batch of pip specs.
``ok`` install succeeded (or nothing was missing).
``blocked`` installs are gated off (config kill switch, sealed venv
without a durable target) or a spec failed validation;
nothing was executed. ``reason`` explains why.
``command`` human-readable description of what ran (for UIs/logs).
"""
ok: bool
blocked: bool = False
reason: str = ""
command: str = ""
stdout: str = ""
stderr: str = ""
def install_specs(specs: list[str] | tuple[str, ...], *, timeout: int = 300) -> InstallSpecsResult:
"""Install arbitrary (validated) pip specs through the lazy-install pipeline.
This is the environment-aware install path for callers whose package
lists come from data (e.g. memory-provider plugin manifests declaring
``pip_dependencies``) rather than the static :data:`LAZY_DEPS` allowlist.
It applies the exact same environment routing as :func:`ensure`:
* **Venv-scoped by default** installs into ``sys.executable``'s venv.
* **Durable-target on immutable images** when the deployment seals the
agent venv (``HERMES_DISABLE_LAZY_INSTALLS=1``) and sets
``HERMES_LAZY_INSTALL_TARGET``, installs are redirected to the writable
data-volume dir (``--target`` + core-venv constraints), then activated
on ``sys.path`` so the packages import in this process immediately.
* **Gated** honors ``security.allow_lazy_installs`` and refuses to run
when the venv is sealed with no durable target (never attempts a write
to a read-only tree; reports *why* instead of surfacing EROFS/EACCES).
Every spec must pass :func:`_spec_is_safe` (no URLs, paths, or shell
metacharacters). Unlike :func:`ensure`, unknown packages are permitted
the caller owns manifest trust; this function owns spec hygiene and
environment routing.
Never raises; inspect the returned :class:`InstallSpecsResult`.
"""
cleaned = tuple(str(s).strip() for s in specs if str(s).strip())
if not cleaned:
return InstallSpecsResult(ok=True, command="")
for spec in cleaned:
if not _spec_is_safe(spec):
return InstallSpecsResult(
ok=False, blocked=True,
reason=f"refusing to install unsafe spec {spec!r}",
)
if not _allow_lazy_installs():
target = _lazy_install_target()
if os.environ.get("HERMES_DISABLE_LAZY_INSTALLS") == "1" and target is None:
reason = (
"runtime installs are disabled on this deployment: the agent "
"environment is immutable and no writable install target is "
"configured (HERMES_LAZY_INSTALL_TARGET)"
)
else:
reason = "runtime installs disabled (security.allow_lazy_installs=false)"
return InstallSpecsResult(ok=False, blocked=True, reason=reason)
target = _lazy_install_target()
display = "uv pip install " + (
f"--target {target} " if target is not None else ""
) + " ".join(cleaned)
logger.info("Installing pip specs %s (target=%s)", " ".join(cleaned), target or "venv")
try:
result = _venv_pip_install(cleaned, timeout=timeout)
except Exception as exc:
logger.warning("install_specs failed unexpectedly: %s", exc)
return InstallSpecsResult(
ok=False, command=display, stderr=f"install failed: {exc}"
)
# Freshly-installed dists must be visible to importers and metadata
# checks in this same process (dashboard rechecks availability inline).
try:
import importlib
importlib.invalidate_caches()
import importlib.metadata as _md
if hasattr(_md, "_cache_clear"):
_md._cache_clear() # type: ignore[attr-defined]
except Exception:
pass
return InstallSpecsResult(
ok=result.success,
command=display,
stdout=result.stdout,
stderr=result.stderr,
)
def active_features() -> list[str]:
"""Return the list of features the user has ever lazy-installed.