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)