mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
2796fca8c9
commit
8bbd77f368
7 changed files with 416 additions and 25 deletions
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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": {}})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue