diff --git a/agent/lsp/install.py b/agent/lsp/install.py index 6710bf94c2..4a658a62db 100644 --- a/agent/lsp/install.py +++ b/agent/lsp/install.py @@ -242,9 +242,17 @@ def _install_npm(pkg: str, bin_name: str, extra_pkgs: Optional[list] = None) -> pm = _node_package_manager() if pm is None: return None - # Managed Node first: $HERMES_HOME/node isn't on an arbitrary process's - # PATH, so a bare which() would miss the Node that Hermes installed. + # npm is Hermes's own PM-managed copy, never the user's; pnpm/yarn are an explicit user choice. pm_bin = find_node_executable(pm) + if pm_bin is None and pm == "npm": + try: + import pm as pm_store + + pm_store.ensure("npm") + pm_bin = find_node_executable("npm") + except Exception as exc: # noqa: BLE001 — a refused/failed PM install skips this server, never the session + logger.warning("[install] cannot install %s: managed npm unavailable (%s)", pkg, exc) + return None if pm_bin is None: # Deliberately no silent fallback to npm: a pnpm/yarn choice is usually a supply-chain policy. logger.warning("[install] cannot install %s: lsp.package_manager is %r but no usable %s was found " diff --git a/hermes_cli/doctor_tools.py b/hermes_cli/doctor_tools.py index ed2e5c4035..cbf083733a 100644 --- a/hermes_cli/doctor_tools.py +++ b/hermes_cli/doctor_tools.py @@ -403,7 +403,8 @@ def _check_lightpanda() -> None: @doctor_check() def _check_node_and_browser(should_fix: bool, f: Finding) -> None: """Node.js, agent-browser resolution, Playwright Chromium, Lightpanda engine.""" - if _safe_which("node"): + # Only PM's Node counts; Termux's APT distribution is the one that relies on `pkg install nodejs`. + if _pm_tool_path("node") or (_is_termux() and _safe_which("node")): check_ok("Node.js") elif _is_termux(): _termux_browser_hints("Node.js not found (browser tools are optional in the tested Termux path)", @@ -431,10 +432,12 @@ def _audit_one(npm_bin: str, npm_dir, label: str, audit_extra: list[str], issues prescribes a local mutating fix command. See #116774. """ import json + from hermes_constants import with_hermes_node_path try: # Resolved absolute path so Windows can execute npm.cmd (CreateProcessW can't run bare .cmd names). audit_result = subprocess.run([npm_bin, "audit", "--json", *audit_extra], cwd=str(npm_dir), - capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=30) + capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=30, + env=with_hermes_node_path()) audit_data = json.loads(audit_result.stdout) if audit_result.stdout.strip() else {} counts = audit_data.get("metadata", {}).get("vulnerabilities", {}) critical, high, moderate = (counts.get(k, 0) for k in ("critical", "high", "moderate")) @@ -468,7 +471,8 @@ def _check_npm_audit(should_fix: bool, f: Finding) -> None: HERMES_HOME mirror rather than the (possibly read-only) Docker install tree, hence the shared resolver. """ from hermes_cli.doctor import PROJECT_ROOT - npm_bin = _safe_which("npm") + staged = _pm_tool_path("npm") + npm_bin = str(staged) if staged else (_safe_which("npm") if _is_termux() else None) if npm_bin: try: # Each entry: (cwd, label, extra_audit_args) PROJECT_ROOT is audited with --workspaces=false so diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 701715b53e..410eea07c9 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -3154,8 +3154,8 @@ def _append_node_dir_for_service(path_entries: list[str], hermes_root: Path | No home's store records node/npm PATH entries, and those dirs — resolved via Facts.env_for — are used verbatim. With managed Node recorded, consulting the invoker's PATH would make a system unit depend on who ran sudo, so - lookup stops there. The legacy ``/node`` tree and finally a PATH - lookup are fallbacks for installs pm never recorded. + lookup stops there. The legacy ``/node`` tree is the only fallback, + for installs pm never recorded; the invoker's PATH node never is. """ home = Path(hermes_root) if hermes_root is not None else Path(get_hermes_home()) try: @@ -3179,19 +3179,6 @@ def _append_node_dir_for_service(path_entries: list[str], hermes_root: Path | No if present and entry not in path_entries: path_entries.append(entry) - # With managed Node present, consulting the invoker's PATH would make a system unit depend on who ran sudo. - if managed_node_present: - return - - resolved_node = shutil.which("node") - if not resolved_node: - return - - # Use the dir where node is FOUND, not the symlink target (~/.local/bin/node often links into one profile). - resolved_node_dir = str(Path(resolved_node).parent) - if resolved_node_dir not in path_entries: - path_entries.append(resolved_node_dir) - def _systemd_command(argv: list[str]) -> str: """Quote argv for systemd, including its non-shell specifier expansion.""" diff --git a/hermes_cli/main_install_repair.py b/hermes_cli/main_install_repair.py index 1755eb1074..7fc37b82ac 100644 --- a/hermes_cli/main_install_repair.py +++ b/hermes_cli/main_install_repair.py @@ -290,29 +290,15 @@ def _is_windows_npm_path(npm_path: str) -> bool: def _resolve_node_runtime_npm() -> str | None: - """Resolve an npm executable that belongs to the host's Node runtime. + """PM's npm, refused on a POSIX host when it is a Windows shim (EISDIR over WSL UNC paths, #30271). - On WSL, PATH interop can hand back a Windows npm that fails with EISDIR / symlink errors over - ``\\\\wsl.localhost\\...`` UNC paths. Refuse it on a POSIX host and re-scan PATH minus the - Windows drive mounts. ``None`` when no suitable npm is reachable. - - On WSL/Linux ``shutil.which("npm")`` may resolve a Windows npm exposed through PATH interop. See #30271. + Never re-scans the user's PATH for another npm: Hermes runs only its PM-managed toolchain. """ from hermes_constants import find_node_executable npm = find_node_executable("npm") - if _is_windows(): + if _is_windows() or not npm: return npm - if not npm: - return None - if not _is_windows_npm_path(npm): - return npm - for directory in os.environ.get("PATH", "").split(os.pathsep): - if not directory or _is_windows_npm_path(directory): - continue - candidate = shutil.which("npm", path=directory) - if candidate and not _is_windows_npm_path(candidate): - return candidate - return None + return None if _is_windows_npm_path(npm) else npm def _resolve_update_branch(args) -> str: diff --git a/hermes_cli/source_build.py b/hermes_cli/source_build.py index 61190cbe2e..b6cccfb37c 100644 --- a/hermes_cli/source_build.py +++ b/hermes_cli/source_build.py @@ -9,12 +9,12 @@ import sys def source_product_current(project_root: Path, product: str, out: Path) -> bool: """Read the compiler's receipt without acquiring tools or dependencies.""" - from pm import env_for + from pm import env_for, installed_package - env = env_for("node") - node = shutil.which("node", path=env.get("PATH", "")) - if not node: - return False + installed = installed_package("node") + if installed is None or installed.binary is None: + return False # Only PM's Node may run the receipt reader; never the user's PATH copy. + node, env = str(installed.binary), env_for("node") try: result = subprocess.run( [node, str(project_root / "scripts/build/freshness.mjs"), diff --git a/hermes_constants.py b/hermes_constants.py index 4ebe47e714..bbdf2e9ea4 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -487,10 +487,12 @@ def hermes_managed_node_tree_present(home: Path | None = None) -> bool: def find_node_executable(command: str) -> str | None: - """Read PM's selected Node/npm/npx, then a user-owned PATH toolchain. + """Read PM's selected Node/npm/npx; ``None`` when PM has not installed it. - Explicit executable paths remain caller-owned. Discovery never installs, - probes, repairs, or activates the retired ``HERMES_HOME/node`` layout. + Never falls back to the user's PATH copy (callers ``pm.ensure`` on ``None``): + mixing toolchains breaks native-addon ABIs and npm caches. Explicit + executable paths remain caller-owned. Discovery never installs, probes, + repairs, or activates the retired ``HERMES_HOME/node`` layout. """ command = str(command) if any(sep in command for sep in ("/", "\\")): @@ -505,14 +507,15 @@ def find_node_executable(command: str) -> str | None: from pm import installed_package installed = installed_package(package_name) - if installed is not None and installed.binary is not None: - if base != "npx": - return str(installed.binary) - for name in _candidate_node_command_names("npx"): - candidate = installed.binary.parent / name - if candidate.is_file(): - return str(candidate) + if installed is None or installed.binary is None: return None + if base != "npx": + return str(installed.binary) + for name in _candidate_node_command_names("npx"): + candidate = installed.binary.parent / name + if candidate.is_file(): + return str(candidate) + return None if sys.platform != "win32": return shutil.which(command) directories = [d for d in os.environ.get("PATH", "").split(os.pathsep) if d] diff --git a/scripts/install.ps1 b/scripts/install.ps1 index c207f6e688..37c20c999e 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -496,16 +496,12 @@ function Invoke-DownloadWithProgress { # (\uv--\), sha256-verified, so pm adopts the same # bytes — no astral-latest, no irm|iex. Returns the uv.exe path. function Get-Uv { - $existing = Get-Command uv -ErrorAction SilentlyContinue - if ($existing) { - # Developer shortcut: fetches nothing, but only for a new-enough uv. - if (Test-UvAtLeastPin $existing.Source) { return $existing.Source } - Log "uv on PATH ($($existing.Source)) is older than the pinned $($script:UvPinVersion) or does not run; downloading our own copy" - } + # Always the pinned artifact, never a uv already on PATH: Hermes runs only + # its own packaged toolchain. $target = "win32-$(Get-WindowsArch)" $pin = $script:UvPinFiles[$target] if (-not $pin) { - Fail "no pinned uv artifact for $target; install uv manually: https://docs.astral.sh/uv/" + Fail "no pinned uv artifact for $target; Hermes does not support this host" } $entry = Join-Path (Get-PmStoreRoot) "uv-$($script:UvPinVersion)-$target" $uvExe = Join-Path $entry "uv.exe" diff --git a/scripts/install.sh b/scripts/install.sh index b272688fce..b2a4bb3006 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -255,21 +255,6 @@ uv_bootstrap_target() { esac } -# version_at_least HAVE WANT: dotted numeric comparison; a pre-release or -# build suffix on a component is ignored ("0.12.3-rc1" reads as 0.12.3). -version_at_least() { - local have="$1" want="$2" h w - while [ -n "$want" ]; do - h="${have%%.*}"; h="${h%%[!0-9]*}" - w="${want%%.*}"; w="${w%%[!0-9]*}" - [ "${h:-0}" -gt "${w:-0}" ] && return 0 - [ "${h:-0}" -lt "${w:-0}" ] && return 1 - case "$have" in *.*) have="${have#*.}" ;; *) have="" ;; esac - case "$want" in *.*) want="${want#*.}" ;; *) want="" ;; esac - done - return 0 -} - # Provision uv for this host from the pinned pm/lock.json artifact. Stages # the EXACT artifact pm itself uses into the same store slot # (/uv--/, the store pm's store_root() resolves), @@ -278,24 +263,14 @@ version_at_least() { UV_CMD="" ensure_uv() { [ -n "$UV_CMD" ] && return 0 - local _path_uv _path_version - if _path_uv="$(command -v uv 2>/dev/null)"; then - # Developer shortcut: a uv on PATH fetches nothing, but only one at - # least as new as the pin -- the bootstrap passes flags older uv - # lacks (`python install --no-bin` arrived in 0.7). - _path_version="$("$_path_uv" --version 2>/dev/null | awk '{print $2}')" - if [ -n "$_path_version" ] && version_at_least "$_path_version" "$UV_PIN_VERSION"; then - UV_CMD="$_path_uv" - return 0 - fi - log_warn "uv on PATH (${_path_version:-does not run}) is older than the pinned $UV_PIN_VERSION; staging the pin" - fi + # Always the pinned artifact, never a uv already on PATH: Hermes runs only + # its own packaged toolchain. local _target if ! _target="$(uv_bootstrap_target)"; then - fail "no pinned uv build for this platform ($(uname -s) $(uname -m)); install uv manually: https://docs.astral.sh/uv/" + fail "no pinned uv build for this platform ($(uname -s) $(uname -m)); Hermes does not support this host" fi if ! uv_bootstrap_pin "$_target"; then - fail "no pinned uv artifact for $_target; install uv manually: https://docs.astral.sh/uv/" + fail "no pinned uv artifact for $_target; Hermes does not support this host" fi local _store="${HERMES_RUNTIME_DIR:-$HERMES_HOME/tools}" local _entry="$_store/uv-$UV_PIN_VERSION-$_target" diff --git a/tests/e2e/core/upgrade/_install_helpers.py b/tests/e2e/core/upgrade/_install_helpers.py index 11d61fc96b..7c104ca591 100644 --- a/tests/e2e/core/upgrade/_install_helpers.py +++ b/tests/e2e/core/upgrade/_install_helpers.py @@ -7,8 +7,8 @@ of that this module stages what a real user machine looks like to the installer the official clone URLs are rewritten to by the sandbox's own ``~/.gitconfig``, plus a ``git`` wrapper that reports the official URL for ``remote get-url origin`` (``insteadOf`` would otherwise expose the local path and send the updater down the fork path); -* the real host uv on PATH as an optional warm-cache shortcut; the installer - still rejects a version below its PM pin and provisions the pinned artifact; +* the real host uv on PATH, which the installer must ignore: it always provisions the pinned + PM artifact; * ``TMPDIR`` inside the sandbox root (the host's is not writable in the sandbox). """ diff --git a/tests/fixtures/resolution_allowlist.json b/tests/fixtures/resolution_allowlist.json index 6b8893b5d8..126be7fd5e 100644 --- a/tests/fixtures/resolution_allowlist.json +++ b/tests/fixtures/resolution_allowlist.json @@ -169,11 +169,6 @@ "symbol": "_safe_which", "kind": "bare_which" }, - { - "path": "hermes_cli/gateway.py", - "symbol": "_append_node_dir_for_service", - "kind": "bare_which" - }, { "path": "hermes_cli/gateway.py", "symbol": "_build_wsl_interop_paths", @@ -299,11 +294,6 @@ "symbol": "cmd_gui", "kind": "bare_which" }, - { - "path": "hermes_cli/main_install_repair.py", - "symbol": "_resolve_node_runtime_npm", - "kind": "bare_which" - }, { "path": "hermes_cli/main_tui_launch.py", "symbol": "_apply_tui_python_env", @@ -379,11 +369,6 @@ "symbol": "run_source_script", "kind": "bare_which" }, - { - "path": "hermes_cli/source_build.py", - "symbol": "source_product_current", - "kind": "bare_which" - }, { "path": "hermes_cli/stdio.py", "symbol": "_default_windows_editor", diff --git a/tests/gateway/test_adapter_connect_classification.py b/tests/gateway/test_adapter_connect_classification.py index fe5c94e455..ef0f0204f6 100644 --- a/tests/gateway/test_adapter_connect_classification.py +++ b/tests/gateway/test_adapter_connect_classification.py @@ -138,8 +138,12 @@ class TestPhotonSidecarStartupClassification: def _make_adapter(self, monkeypatch): monkeypatch.setenv("PHOTON_PROJECT_ID", "pid") monkeypatch.setenv("PHOTON_PROJECT_SECRET", "psecret") + from plugins.platforms.photon import adapter as photon_adapter from plugins.platforms.photon.adapter import PhotonAdapter + # Stand-in for PM's node; the user's PATH node is never picked up. + monkeypatch.setattr(photon_adapter, "find_node_executable", lambda _name: "/pm/node") + return PhotonAdapter(PlatformConfig(enabled=True, token="", extra={})) @pytest.mark.asyncio diff --git a/tests/gateway/test_whatsapp_connect.py b/tests/gateway/test_whatsapp_connect.py index 40da4d16cf..2c58c1aa2c 100644 --- a/tests/gateway/test_whatsapp_connect.py +++ b/tests/gateway/test_whatsapp_connect.py @@ -27,6 +27,14 @@ from gateway.platforms.base import SendResult # Helpers # --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def _pm_node(monkeypatch): + """Stand-in for PM's Node/npm; the user's PATH copy is never picked up.""" + from plugins.platforms.whatsapp import adapter as whatsapp_adapter + monkeypatch.setattr(whatsapp_adapter, "find_node_executable", lambda name: f"/pm/{name}") + + def _make_adapter(): """Create a WhatsAppAdapter with test attributes (bypass __init__).""" from plugins.platforms.whatsapp.adapter import WhatsAppAdapter diff --git a/tests/gateway/test_whatsapp_stale_bridge.py b/tests/gateway/test_whatsapp_stale_bridge.py index 36cc6c3cf8..3ad226de14 100644 --- a/tests/gateway/test_whatsapp_stale_bridge.py +++ b/tests/gateway/test_whatsapp_stale_bridge.py @@ -38,6 +38,14 @@ class _AsyncCM: return False + +@pytest.fixture(autouse=True) +def _pm_node(monkeypatch): + """Stand-in for PM's Node/npm; the user's PATH copy is never picked up.""" + from plugins.platforms.whatsapp import adapter as whatsapp_adapter + monkeypatch.setattr(whatsapp_adapter, "find_node_executable", lambda name: f"/pm/{name}") + + def _make_adapter(bridge_script: str = "/tmp/test-bridge.js", session_path: Path = Path("/tmp/test-wa-session")): """Create a WhatsAppAdapter with test attributes (bypass __init__).""" diff --git a/tests/hermes_cli/test_desktop_update_verify.py b/tests/hermes_cli/test_desktop_update_verify.py index 241e52839d..b9516be01b 100644 --- a/tests/hermes_cli/test_desktop_update_verify.py +++ b/tests/hermes_cli/test_desktop_update_verify.py @@ -5,7 +5,7 @@ import struct import pytest from hermes_cli import desktop_update_verify as verify -from tests.hermes_cli.test_source_build import copy_freshness_scripts, stamp_product +from tests.hermes_cli.test_source_build import copy_freshness_scripts, stamp_product, use_host_node_as_pm_node @pytest.fixture @@ -31,6 +31,7 @@ def bundle(tmp_path, monkeypatch): monkeypatch.setattr(main_desktop, '_desktop_packaged_executable', lambda _: resources.parent / 'Hermes.exe') copy_freshness_scripts(tmp_path) stamp_product(tmp_path, "desktop", dist) + use_host_node_as_pm_node(monkeypatch) return tmp_path, archive, dist diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index 366e31f8d1..d1dc8ee056 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -360,48 +360,6 @@ class TestGeneratedSystemdUnits: assert "LD_LIBRARY_PATH" not in gateway_cli.generate_systemd_unit(system=False) - def test_user_unit_does_not_leak_profile_node_symlink_target(self, tmp_path, monkeypatch): - # Regression for the multi-profile gateway restart-loop flap (#48700): - # ~/.local/bin/node is often a symlink into a *specific* profile's node - # install. The generated unit's PATH must contain the symlink's own - # directory (~/.local/bin), NOT the resolved profile target — otherwise - # one profile's node path leaks into every profile's unit, making - # systemd_unit_is_current() perpetually false and forcing a - # daemon-reload restart loop on every boot. - local_bin = tmp_path / ".local" / "bin" - profile_node_bin = tmp_path / ".hermes" / "profiles" / "jarvis" / "node" / "bin" - local_bin.mkdir(parents=True) - profile_node_bin.mkdir(parents=True) - real_node = profile_node_bin / "node" - real_node.write_text("#!/bin/sh\n") - link_node = local_bin / "node" - link_node.symlink_to(real_node) - - monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: str(link_node) if cmd == "node" else None) - - unit = gateway_cli.generate_systemd_unit(system=False) - - assert str(local_bin) in unit - assert str(profile_node_bin) not in unit - - def test_launchd_plist_does_not_leak_profile_node_symlink_target(self, tmp_path, monkeypatch): - # Same #48700 regression for the macOS twin generate_launchd_plist(). - local_bin = tmp_path / ".local" / "bin" - profile_node_bin = tmp_path / ".hermes" / "profiles" / "jarvis" / "node" / "bin" - local_bin.mkdir(parents=True) - profile_node_bin.mkdir(parents=True) - real_node = profile_node_bin / "node" - real_node.write_text("#!/bin/sh\n") - link_node = local_bin / "node" - link_node.symlink_to(real_node) - - monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: str(link_node) if cmd == "node" else None) - - plist = gateway_cli.generate_launchd_plist() - - assert str(local_bin) in plist - assert str(profile_node_bin) not in plist - def test_launchd_plist_persists_configured_nofile_soft_limit(self, monkeypatch): """The generated plist must carry SoftResourceLimits/NumberOfFiles so a plist rewrite by `hermes gateway start` cannot strip the FD floor and @@ -1361,9 +1319,10 @@ def _seed_pm_node_facts(hermes_root): class TestSystemUnitHermesHome: """HERMES_HOME in system units must reference the target user, not root.""" - def test_no_pm_node_facts_uses_only_ambient_fallback( + def test_no_pm_node_never_bakes_the_invokers_path_node( self, monkeypatch, tmp_path ): + """Hermes runs only its PM-managed Node: a PATH node is never written into a unit.""" (tmp_path / ".hermes" / "tools").mkdir(parents=True) monkeypatch.setattr( gateway_cli.shutil, "which", lambda name: "/opt/external-node/bin/node" @@ -1372,12 +1331,12 @@ class TestSystemUnitHermesHome: gateway_cli._append_node_dir_for_service(entries, tmp_path / ".hermes") - assert entries == ["/opt/external-node/bin"] + assert entries == [] - def test_stale_pm_facts_without_dirs_use_only_ambient_fallback( + def test_stale_pm_facts_without_dirs_contribute_nothing( self, monkeypatch, tmp_path ): - """Recorded entries whose store dirs are gone contribute nothing.""" + """Recorded entries whose store dirs are gone contribute nothing, and no PATH node replaces them.""" import shutil as _shutil hermes_root = tmp_path / ".hermes" @@ -1390,7 +1349,7 @@ class TestSystemUnitHermesHome: gateway_cli._append_node_dir_for_service(entries, hermes_root) - assert entries == ["/opt/external-node/bin"] + assert entries == [] def test_managed_node_makes_system_unit_independent_of_callers_path( self, monkeypatch, tmp_path @@ -1424,19 +1383,6 @@ class TestSystemUnitHermesHome: assert managed_dir in root_unit assert "/root/bin" not in root_unit - def test_node_path_lookup_remains_fallback_without_managed_node( - self, monkeypatch, tmp_path - ): - """External Node installs still work when pm has no node installed.""" - monkeypatch.setattr( - gateway_cli.shutil, "which", lambda name: "/opt/external-node/bin/node" - ) - entries: list[str] = [] - - gateway_cli._append_node_dir_for_service(entries, tmp_path / ".hermes") - - assert entries == ["/opt/external-node/bin"] - def test_system_unit_orders_after_target_user_manager(self, monkeypatch, tmp_path): """#104893: restart-safe workers need user@.service; the system unit must not race it at boot.""" monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) diff --git a/tests/hermes_cli/test_node_runtime_npm_resolution.py b/tests/hermes_cli/test_node_runtime_npm_resolution.py index 80d6eaf97e..e0eb5651ca 100644 --- a/tests/hermes_cli/test_node_runtime_npm_resolution.py +++ b/tests/hermes_cli/test_node_runtime_npm_resolution.py @@ -18,17 +18,13 @@ def test_windows_npm_path_refuses_windows_shims_but_not_native_data_mounts(): assert not _is_windows_npm_path("/usr/bin/npm") -def test_resolve_node_runtime_npm_rescans_past_windows_drive_to_native_mount(monkeypatch): - """When PATH interop hands back a Windows npm first, the re-scan skips the Windows drive - mount but still accepts a native npm living under ``/mnt/data``.""" - native_npm = "/mnt/data/node/bin/npm" +def test_resolve_node_runtime_npm_never_rescans_path_past_a_windows_shim(monkeypatch): + """A Windows npm shim is refused on a POSIX host, and no other npm is hunted down on the + user's PATH: Hermes runs only its PM-managed toolchain.""" monkeypatch.setenv("PATH", os.pathsep.join(["/mnt/c/Program Files/nodejs", "/mnt/data/node/bin"])) - def fake_which(cmd, path=None): - return native_npm if path == "/mnt/data/node/bin" else None - with ( patch("hermes_constants.find_node_executable", return_value="/mnt/c/Program Files/nodejs/npm"), - patch("hermes_cli.main_install_repair.shutil.which", side_effect=fake_which), + patch("shutil.which", return_value="/mnt/data/node/bin/npm"), ): - assert _resolve_node_runtime_npm() == native_npm + assert _resolve_node_runtime_npm() is None diff --git a/tests/hermes_cli/test_source_build.py b/tests/hermes_cli/test_source_build.py index a623f1ad56..92e4ea5ce4 100644 --- a/tests/hermes_cli/test_source_build.py +++ b/tests/hermes_cli/test_source_build.py @@ -1,6 +1,6 @@ """Source orchestration uses real node-deps/npm in an isolated checkout. -Only PM's tool acquisition is substituted with the host's node/npm. Small +Only PM's tool acquisition (and its installed-node record) is substituted with the host's node/npm. Small workspace scripts stand in for the expensive UI compilers; subprocess failures, locked dependency selection, environment propagation and publication are real. """ @@ -12,6 +12,7 @@ from pathlib import Path import shutil import subprocess import sys +from types import SimpleNamespace import pytest @@ -19,6 +20,13 @@ import pm from pm.package import Runner +def use_host_node_as_pm_node(monkeypatch): + """Freshness reads run only under PM's recorded Node; stand the host's node in for it.""" + node, real = Path(shutil.which("node")), pm.installed_package + monkeypatch.setattr(pm, "installed_package", lambda name, **kwargs: ( + SimpleNamespace(binary=node) if name == "node" else real(name, **kwargs))) + + def copy_freshness_scripts(root): repository = Path(__file__).resolve().parents[2] scripts = root / "scripts/build" @@ -108,6 +116,7 @@ def source_checkout(tmp_path, monkeypatch): [str(Path(npm).parent), str(Path(node).parent), os.environ["PATH"]])}) monkeypatch.setattr(pm, "ensure", acquire) + use_host_node_as_pm_node(monkeypatch) root = tmp_path / "source with spaces" root.mkdir() workspaces = ["ui-tui", "web", "apps/desktop", "unrelated"] diff --git a/tests/hermes_cli/test_web_ui_build.py b/tests/hermes_cli/test_web_ui_build.py index f67a765347..f14524b9c2 100644 --- a/tests/hermes_cli/test_web_ui_build.py +++ b/tests/hermes_cli/test_web_ui_build.py @@ -7,7 +7,7 @@ from unittest.mock import patch import pytest from hermes_cli.main_web_build import _build_web_ui, _web_ui_build_needed -from tests.hermes_cli.test_source_build import stamp_product, copy_freshness_scripts +from tests.hermes_cli.test_source_build import stamp_product, copy_freshness_scripts, use_host_node_as_pm_node from tests.hermes_cli.test_source_build import source_checkout, source_products, _events # noqa: F401 @@ -93,6 +93,7 @@ def test_contended_build_waits_and_rechecks_winner(tmp_path, monkeypatch, existi import fcntl import threading + use_host_node_as_pm_node(monkeypatch) web, dist = _make_web_dir(tmp_path) if existing: _touch(dist / 'index.html') diff --git a/tests/plugins/platforms/photon/test_npm_error_log_regression.py b/tests/plugins/platforms/photon/test_npm_error_log_regression.py index 789ac399bc..9d95471ab2 100644 --- a/tests/plugins/platforms/photon/test_npm_error_log_regression.py +++ b/tests/plugins/platforms/photon/test_npm_error_log_regression.py @@ -201,6 +201,7 @@ def test_regression_stale_log_not_surfaced_after_successful_reinstall( # Create spectrum-ts inside node_modules/ — the content check requires it. (tmp_path / "node_modules" / "spectrum-ts").mkdir(parents=True) monkeypatch.setattr(adapter_mod, "HTTPX_AVAILABLE", True) + monkeypatch.setattr(adapter_mod, "find_node_executable", lambda _name: "/pm/node") monkeypatch.setattr(sidecar_paths, "_SIDECAR_DIR", tmp_path) monkeypatch.setattr(sidecar_paths, "_NPM_ERROR_LOG", error_log) @@ -223,6 +224,7 @@ def test_regression_debug_log_emitted_even_without_error_log( setup, not a failed install), check_requirements() must still emit a DEBUG line pointing to the sidecar path.""" monkeypatch.setattr(adapter_mod, "HTTPX_AVAILABLE", True) + monkeypatch.setattr(adapter_mod, "find_node_executable", lambda _name: "/pm/node") monkeypatch.setattr(sidecar_paths, "_SIDECAR_DIR", tmp_path) monkeypatch.setattr(sidecar_paths, "_NPM_ERROR_LOG", tmp_path / ".photon-npm-error.log") # NS-606: disable self-heal so the debug-log branch is reached. diff --git a/tests/plugins/platforms/photon/test_runtime_record.py b/tests/plugins/platforms/photon/test_runtime_record.py index 03bba08710..7854a03edd 100644 --- a/tests/plugins/platforms/photon/test_runtime_record.py +++ b/tests/plugins/platforms/photon/test_runtime_record.py @@ -36,6 +36,8 @@ def record_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter: monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id") monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret") + # Stand-in for PM's node; the user's PATH node is never picked up. + monkeypatch.setattr(photon_adapter, "find_node_executable", lambda _name: "/pm/node") monkeypatch.delenv("PHOTON_SIDECAR_TOKEN", raising=False) cfg = PlatformConfig(enabled=True, token="", extra={}) return PhotonAdapter(cfg) diff --git a/tests/plugins/platforms/photon/test_sidecar_lifecycle.py b/tests/plugins/platforms/photon/test_sidecar_lifecycle.py index fd1657af08..b1fe86ba6c 100644 --- a/tests/plugins/platforms/photon/test_sidecar_lifecycle.py +++ b/tests/plugins/platforms/photon/test_sidecar_lifecycle.py @@ -22,6 +22,8 @@ from plugins.platforms.photon.adapter import PhotonAdapter def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter: monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id") monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret") + # Stand-in for PM's node; the user's PATH node is never picked up. + monkeypatch.setattr(photon_adapter, "find_node_executable", lambda _name: "/pm/node") cfg = PlatformConfig(enabled=True, token="", extra={}) return PhotonAdapter(cfg) diff --git a/tests/pm/test_node_resolution.py b/tests/pm/test_node_resolution.py index dd062581b6..4922951eb4 100644 --- a/tests/pm/test_node_resolution.py +++ b/tests/pm/test_node_resolution.py @@ -115,14 +115,14 @@ def test_passive_discovery_never_installs_or_repairs_a_legacy_tree(tmp_path, mon assert {p.relative_to(home): p.read_bytes() for p in home.rglob("*") if p.is_file()} == before assert not (home / "tools").exists() - # A user-owned PATH toolchain remains usable without acquiring a PM one. + # A user-owned PATH toolchain is never selected: Hermes runs only PM's copy. external = tmp_path / "external" external.mkdir() npm = external / "npm" npm.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") npm.chmod(0o755) monkeypatch.setenv("PATH", str(external)) - assert hermes_constants.find_node_executable("npm") == str(npm) + assert hermes_constants.find_node_executable("npm") is None assert attempts == [] assert not (home / "tools").exists() diff --git a/tests/scripts/install/test_install_sh_repository_stage.py b/tests/scripts/install/test_install_sh_repository_stage.py index c5fe4256ef..ab5e950811 100644 --- a/tests/scripts/install/test_install_sh_repository_stage.py +++ b/tests/scripts/install/test_install_sh_repository_stage.py @@ -141,22 +141,17 @@ def test_rerun_follows_an_explicit_repo_url(tmp_path): assert _git(install, "remote", "get-url", "origin") == moved.as_posix() -@pytest.mark.parametrize("reported, accepted", [("0.6.17", False), ("99.0.0", True)]) -def test_path_uv_is_used_only_when_at_least_the_pin(tmp_path, reported, accepted): +def test_path_uv_is_never_used_even_when_newer_than_the_pin(tmp_path): bindir = tmp_path / "bin" bindir.mkdir() fake = bindir / "uv" - fake.write_text(f"#!/bin/sh\necho 'uv {reported}'\n") + fake.write_text("#!/bin/sh\necho 'uv 99.0.0'\n") fake.chmod(0o755) - # No pinned target: rejecting the PATH uv must surface as a failure to stage one. + # No pinned target: with the PATH uv ignored, the install must fail rather than adopt it. body = 'uv_bootstrap_target() { return 1; }\nensure_uv\necho "UV_CMD=$UV_CMD"' result = _run(tmp_path, body, env={"PATH": f"{bindir}:{os.environ['PATH']}"}) - if accepted: - assert result.returncode == 0, result.stdout + result.stderr - assert f"UV_CMD={fake}" in result.stdout - else: - assert result.returncode != 0 - assert "older than the pinned" in result.stdout + result.stderr + assert result.returncode != 0 + assert f"UV_CMD={fake}" not in result.stdout def test_interactive_stages_skip_without_a_terminal(tmp_path): diff --git a/tests/test_managed_runtime_resolution.py b/tests/test_managed_runtime_resolution.py index b1bff84c0a..825fff79bf 100644 --- a/tests/test_managed_runtime_resolution.py +++ b/tests/test_managed_runtime_resolution.py @@ -77,19 +77,12 @@ _ALLOWED: dict[tuple[str, str], str] = { "can only run what is on that subshell's PATH, which local.py populates " "with the managed dirs — so PATH is the correct question to ask here." ), - ("hermes_cli/gateway.py", "node"): ( - "Fallback rung of _append_node_dir_for_service(), after the managed " - "dirs from iter_hermes_node_dirs() are already appended." - ), ("hermes_cli/main_tui_launch.py", "node"): ( - "_ensure_tui_node()'s idempotence gate: the question really is 'is " - "node already discoverable on PATH', before bootstrapping one." + "PM-composed launch context: `which(node)` runs against the PATH " + "pm.ensure('node'|'npm') just composed (store dirs first), not the ambient one." ), ("hermes_cli/main_tui_launch.py", "npm"): ( - "Same _ensure_tui_node() gate as node." - ), - ("hermes_cli/main_install_repair.py", "npm"): ( - "_resolve_node_runtime_npm()'s WSL re-scan: PATH minus /mnt/* IS the question." + "Same PM-composed launch context as the node lookup above." ), ("hermes_cli/source_build.py", "node"): ( "PM-composed build context: `which(node)` runs against the PATH pm's " diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index b3013f68a2..b26e26b2c0 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -150,7 +150,7 @@ You don't need to rebuild your setup from scratch. Restore a full backup with `h For the POSIX source script, provide Git, curl, tar, and SHA-256 utilities. Windows can bootstrap its pinned Git for Windows archive when Git is absent. -An existing uv can bootstrap PM; otherwise the script downloads its verified pin. +The script always downloads its verified uv pin; a uv already on your PATH is never used. Current first-party installations run on **Python 3.14**. The broader `>=3.11,<3.15` range in `pyproject.toml` lets older Python installations