fix(tools): make managed Node authoritative in the MCP stdio PATH

`_prepend_path` inserted the resolved command's directory only when it was
absent from the child's PATH. The Hermes installer appends its managed Node
dir to the user PATH, so for anyone with a system Node (<22.12) earlier on
PATH the check no-oped and the managed dir stayed behind it. npm lifecycle
children (`node install.js`) then resolved the older system Node and failed
with ERR_REQUIRE_ESM even though Hermes had provisioned a compatible runtime.

Strip every existing case/trailing-separator variant of the directory first,
then prepend it, so the canonical entry is the one that wins and PATH does
not grow duplicates.

Fixes #82309
This commit is contained in:
Austin Pickett
2026-09-27 00:38:21 -04:00
parent 652fb2eb57
commit 79dbb1450e
2 changed files with 85 additions and 4 deletions

View File

@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from tools.mcp_tool import MCPServerTask, _MCP_AVAILABLE
from tools.mcp_tool_errors import _format_connect_error
from tools.mcp_tool_common import _prepend_path
from tools.mcp_tool_config import _node_fallback, _resolve_stdio_command
from tools.mcp_tool_config import _which_with_config_pathext
@@ -340,3 +341,66 @@ def test_run_stdio_malware_check_times_out_fail_open():
assert elapsed < 1.0, f"startup did not fail-open promptly ({elapsed:.1f}s)"
asyncio.run(_test())
# ---------------------------------------------------------------------------
# #82309: a managed dir that is ALREADY on the child's PATH (the Hermes
# installer appends its managed Node dir) must still end up FIRST. "Prepend
# only when absent" left the older system Node ahead of it, so npm lifecycle
# children (`node install.js`) resolved the system Node and died with
# ERR_REQUIRE_ESM even though Hermes had provisioned a compatible runtime.
# ---------------------------------------------------------------------------
def test_prepend_path_makes_an_already_present_dir_first():
"""The reported layout — system Node first, managed dir already present
behind it — must come out with the managed dir first."""
managed = os.path.join(os.sep, "managed", "node", "bin")
system = os.path.join(os.sep, "system", "node")
env = _prepend_path({"PATH": os.pathsep.join([system, "mid-dir", managed]), "KEEP": "v"}, managed)
assert env["PATH"].split(os.pathsep) == [managed, system, "mid-dir"]
assert env["KEEP"] == "v"
def test_prepend_path_collapses_duplicate_entries():
"""Prepending an already-present dir must not grow PATH a duplicate."""
managed = "/managed/node/bin"
env = _prepend_path({"PATH": os.pathsep.join([managed, "/usr/bin", managed])}, managed)
assert env["PATH"].split(os.pathsep) == [managed, "/usr/bin"]
def test_prepend_path_collapses_windows_case_and_separator_variants(monkeypatch):
"""Windows PATH lookup is case- and separator-insensitive, so every variant
of the managed dir has to be removed for the canonical entry to win."""
monkeypatch.setattr(os, "pathsep", ";")
monkeypatch.setattr(sys, "platform", "win32")
managed = r"C:\Users\x\AppData\Local\hermes\node"
variant = "c:\\users\\x\\appdata\\local\\hermes\\node" + "\\"
env = _prepend_path(
{"PATH": ";".join([r"C:\Program Files\nodejs", variant, r"C:\tools"])}, managed
)
assert env["PATH"].split(";") == [managed, r"C:\Program Files\nodejs", r"C:\tools"]
def test_resolve_stdio_command_displaces_a_system_node_already_on_path(tmp_path, monkeypatch):
"""End-to-end: with the managed dir already on the child PATH behind a system
Node dir, the resolved env must put the managed dir first so the spawned
launcher's shebang/children (`/usr/bin/env node`) get the managed Node."""
node_bin = tmp_path / "node" / "bin"
node_bin.mkdir(parents=True)
npx_path = node_bin / "npx"
npx_path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
npx_path.chmod(0o755)
system_bin = tmp_path / "system-node"
system_bin.mkdir()
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
inherited = os.pathsep.join([str(system_bin), "/usr/bin", str(node_bin)])
with patch("tools.mcp_tool_config.shutil.which", return_value=None):
command, env = _resolve_stdio_command("npx", {"PATH": inherited})
assert command == str(npx_path)
assert env["PATH"].split(os.pathsep) == [str(node_bin), str(system_bin), "/usr/bin"]

View File

@@ -3,9 +3,11 @@ error-text sanitising, numeric/bool coercion, timeouts and jitter. No origin sta
import logging
import math
import ntpath
import os
import random
import re
import sys
from typing import Any, Optional
logger = logging.getLogger("tools.mcp_tool")
@@ -94,14 +96,29 @@ def _exc_str(exc: BaseException) -> str:
return text or repr(exc)
def _path_comparison_key(entry: str) -> str:
"""Normalize a PATH entry under the active platform's path semantics, so
``C:\\Node\\`` and ``c:\\node`` compare equal on Windows and stay distinct on POSIX."""
path_module = ntpath if sys.platform == "win32" else os.path
return path_module.normcase(path_module.normpath(entry))
def _prepend_path(env: dict, directory: str) -> dict:
"""Prepend *directory* to env PATH if it is not already present."""
"""Make *directory* the FIRST PATH entry, collapsing existing variants of it.
Prepending only when *directory* was absent left a directory that is already
on PATH — the Hermes installer appends its managed Node dir — behind an older
system Node. npm lifecycle children (`node install.js`) then resolve the
system Node through PATH and die with ERR_REQUIRE_ESM (#82309). Every
existing case/trailing-separator variant is stripped first, so this entry is
the one that wins and PATH does not grow duplicates.
"""
updated = dict(env or {})
if directory:
parts = [part for part in updated.get("PATH", "").split(os.pathsep) if part]
if directory not in parts:
parts = [directory, *parts]
updated["PATH"] = os.pathsep.join(parts) if parts else directory
key = _path_comparison_key(directory)
remaining = [part for part in parts if _path_comparison_key(part) != key]
updated["PATH"] = os.pathsep.join([directory, *remaining])
return updated