fix: Hermes never falls back to the user's node/npm/npx/uv
Maintainer ruling: only Hermes and only its packaged package managers
(uv/pip/node/npm) are ever used; no PATH fallback when the managed tool is
missing, no "prefer the user's if new enough".
- hermes_constants.find_node_executable: node/npm/npx resolve to PM's
installed copy or None. Every caller already pm.ensure()s on None, so a
missing runtime is now provisioned instead of silently borrowing the
user's Node (native-addon ABI / npm cache mismatches).
- agent/lsp/install._install_npm: pm.ensure('npm') when PM npm is absent,
instead of failing over to whatever npm is on PATH.
- gateway._append_node_dir_for_service: stop baking the invoker's PATH node
dir into generated systemd/launchd units.
- main_install_repair._resolve_node_runtime_npm: drop the PATH re-scan for
another npm.
- source_build.source_product_current: run the freshness reader only with
PM's node.
- doctor: Node/npm rows and npm audit use PM's copies (Termux APT distro
keeps its system Node).
- install.sh ensure_uv / install.ps1 Get-Uv: always stage the pinned uv
artifact; delete the "uv on PATH if new enough" developer shortcut.
This commit is contained in:
@@ -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 "
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ``<hermes>/node`` tree and finally a PATH
|
||||
lookup are fallbacks for installs pm never recorded.
|
||||
lookup stops there. The legacy ``<hermes>/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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -496,16 +496,12 @@ function Invoke-DownloadWithProgress {
|
||||
# (<store>\uv-<version>-<target>\), 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"
|
||||
|
||||
@@ -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
|
||||
# (<store>/uv-<version>-<target>/, 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"
|
||||
|
||||
@@ -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).
|
||||
"""
|
||||
|
||||
|
||||
15
tests/fixtures/resolution_allowlist.json
vendored
15
tests/fixtures/resolution_allowlist.json
vendored
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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@<uid>.service; the system unit must not race it at boot."""
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user