diff --git a/hermes_constants.py b/hermes_constants.py index 3b37e671e7d..3fe36da7f1c 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -277,8 +277,44 @@ def _candidate_node_command_names(command: str) -> list[str]: return [f"{base}.cmd", f"{base}.exe", base] +def node_tool_runnable(path: str | None) -> bool: + """Return True only when *path* is a Node/npm/npx binary that actually runs. + + Hermes-managed Node trees live under ``$HERMES_HOME/node`` (or a profile's + ``HERMES_HOME``). A partial upgrade or interrupted install can leave + ``bin/npm`` behind while ``lib/cli.js`` is missing — the wrapper exists but + immediately throws ``MODULE_NOT_FOUND``. ``find_hermes_node_executable`` + used to trust file presence alone, so ``hermes update`` would pick that + broken npm and skip the healthy system one on PATH. + + Probe with ``--version`` (same pattern as :func:`agent_browser_runnable`) so + callers can fall through to the next resolution candidate. + """ + if not path: + return False + candidate = Path(path) + if sys.platform == "win32": + if not candidate.is_file(): + return False + elif not os.path.exists(path) or not os.access(path, os.X_OK): + return False + + import subprocess + + try: + result = subprocess.run( + [path, "--version"], + capture_output=True, + timeout=10, + env=with_hermes_node_path(), + ) + except (OSError, subprocess.TimeoutExpired, ValueError): + return False + return result.returncode == 0 + + def find_hermes_node_executable(command: str) -> str | None: - """Return a Hermes-managed Node/npm executable path, if installed.""" + """Return a Hermes-managed Node/npm executable path, if installed and runnable.""" names = _candidate_node_command_names(command) for directory in iter_hermes_node_dirs(): for name in names: @@ -286,7 +322,9 @@ def find_hermes_node_executable(command: str) -> str | None: if candidate.is_file() and ( sys.platform == "win32" or os.access(candidate, os.X_OK) ): - return str(candidate) + resolved = str(candidate) + if node_tool_runnable(resolved): + return resolved return None @@ -319,10 +357,12 @@ def find_node_executable_on_path(command: str) -> str | None: def find_node_executable(command: str) -> str | None: - """Resolve a Node.js command, preferring Hermes-managed installs. + """Resolve a Node.js command, preferring healthy Hermes-managed installs. This is for Hermes-owned subprocesses that should not be broken by a bad, - missing, or elevation-triggering system Node/npm on PATH. + missing, or elevation-triggering system Node/npm on PATH. A managed tree + that exists on disk but fails a ``--version`` probe is skipped so PATH can + supply a working system install instead. """ return find_hermes_node_executable(command) or find_node_executable_on_path(command)