From 8bbd77f368513fa970b31084d480df309785205b Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Mon, 27 Jul 2026 10:32:21 +1000 Subject: [PATCH] fix: route memory-provider dep installs through lazy_deps durable target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- hermes_cli/memory_setup.py | 14 ++- hermes_cli/web_server.py | 45 +++++--- plugins/memory/honcho/cli.py | 11 +- plugins/memory/mem0/_setup.py | 16 ++- tests/hermes_cli/test_web_server.py | 101 ++++++++++++++++++ tests/tools/test_lazy_deps.py | 155 ++++++++++++++++++++++++++++ tools/lazy_deps.py | 99 ++++++++++++++++++ 7 files changed, 416 insertions(+), 25 deletions(-) diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index 3d8e12d572e4..887ad1497855 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -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}") diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index abd26e6b603f..763aa496614c 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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, + ), ) ] diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 7c2b87a8ba1c..caec2f974873 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -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") diff --git a/plugins/memory/mem0/_setup.py b/plugins/memory/mem0/_setup.py index 5eaa3dc4c8ee..368aec34176f 100644 --- a/plugins/memory/mem0/_setup.py +++ b/plugins/memory/mem0/_setup.py @@ -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: diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index b3dfc7dfe462..868e609e988e 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -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": {}}) diff --git a/tests/tools/test_lazy_deps.py b/tests/tools/test_lazy_deps.py index 0063e7680603..88d31a0fb334 100644 --- a/tests/tools/test_lazy_deps.py +++ b/tests/tools/test_lazy_deps.py @@ -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 diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index faa919d028f6..c965e1734af3 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -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.