From 0227872bf574ba9fcee6598987114f236609f2ec Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:55:35 -0700 Subject: [PATCH] fix(hindsight): route setup + auto-upgrade installs through lazy_deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- plugins/memory/hindsight/__init__.py | 53 ++++++-------- .../plugins/memory/test_hindsight_provider.py | 70 ++++++++++++++++++- 2 files changed, 90 insertions(+), 33 deletions(-) diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index d99e1db4884a..736b255b1349 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -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 diff --git a/tests/plugins/memory/test_hindsight_provider.py b/tests/plugins/memory/test_hindsight_provider.py index 0d420a5e9954..945e90f683d3 100644 --- a/tests/plugins/memory/test_hindsight_provider.py +++ b/tests/plugins/memory/test_hindsight_provider.py @@ -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)