diff --git a/tests/tools/test_mcp_tool_issue_948.py b/tests/tools/test_mcp_tool_issue_948.py index 230668b4a1..39b3ccac01 100644 --- a/tests/tools/test_mcp_tool_issue_948.py +++ b/tests/tools/test_mcp_tool_issue_948.py @@ -7,7 +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_config import _resolve_stdio_command +from tools.mcp_tool_config import _node_fallback, _resolve_stdio_command # Ensure the mcp module symbols exist for patching even when the SDK isn't installed if not _MCP_AVAILABLE: @@ -35,6 +35,45 @@ def test_resolve_stdio_command_falls_back_to_hermes_node_bin(tmp_path): assert env["PATH"].split(os.pathsep)[0] == str(node_bin) +def test_windows_managed_node_root_prefers_cmd_launchers(tmp_path): + """Managed Windows Node lives directly in ``/node`` (no ``bin``) as ``npx.cmd`` / + ``npm.cmd`` / ``node.exe``; a bare ``command: npx`` must resolve to those launchers (#111937). + The extensionless POSIX sibling is a shell script Windows cannot spawn, so it must never win.""" + node_root = tmp_path / "node" + node_root.mkdir() + for name in ("npx", "npm", "npx.cmd", "npm.cmd", "node.exe"): + launcher = node_root / name + launcher.write_text("@echo off\r\n", encoding="utf-8") + launcher.chmod(0o755) + + with patch.dict("os.environ", {"HERMES_HOME": str(tmp_path)}, clear=False): + assert _node_fallback("npx", windows=True) == str(node_root / "npx.cmd") + assert _node_fallback("npm", windows=True) == str(node_root / "npm.cmd") + assert _node_fallback("node", windows=True) == str(node_root / "node.exe") + + +def test_node_fallback_uses_active_profile_home(tmp_path, monkeypatch): + """The managed-Node lookup follows ``get_hermes_home()`` (context override), not raw ``HERMES_HOME``: + a multiplexed profile whose home differs from the launch env must find ITS managed Node.""" + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + + profile_home = tmp_path / "profile" + npx_path = profile_home / "node" / "bin" / "npx" + npx_path.parent.mkdir(parents=True) + npx_path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + npx_path.chmod(0o755) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "launch-home")) + monkeypatch.setenv("HOME", str(tmp_path / "user")) # keep a real ~/.local/bin/npx out of the picture + + token = set_hermes_home_override(profile_home) + try: + with patch("tools.mcp_tool_config.shutil.which", return_value=None): + command, _env = _resolve_stdio_command("npx", {"PATH": "/usr/bin"}) + finally: + reset_hermes_home_override(token) + assert command == str(npx_path) + + def test_resolve_stdio_command_falls_back_to_usr_local_bin(): """When ``npx`` isn't on the filtered PATH and isn't under ``$HERMES_HOME/node/bin`` or ``~/.local/bin``, the resolver should still locate it at ``/usr/local/bin/npx``. diff --git a/tools/mcp_tool_config.py b/tools/mcp_tool_config.py index 19dcc21051..b4a8be01b2 100644 --- a/tools/mcp_tool_config.py +++ b/tools/mcp_tool_config.py @@ -149,14 +149,19 @@ def _which_with_config_pathext(command: str, path_arg, env: dict): os.environ["PATHEXT"] = saved -def _node_fallback(command: str) -> str: - """Well-known Node install locations for bare ``npx``/``npm``/``node``; *command* unchanged when none exists.""" +def _node_fallback(command: str, *, windows: Optional[bool] = None) -> str: + """Well-known Node install locations for bare ``npx``/``npm``/``node``; *command* unchanged when none exists. + + The managed tree comes from ``iter_hermes_node_dirs`` (Windows unpacks into ``\\node``, POSIX into + ``/node/bin``) under the active profile's ``get_hermes_home()``; on Windows the real files are + ``npx.cmd``/``node.exe`` (``windows`` injectable, as for ``_npx_bin_candidates``).""" + from hermes_constants import get_hermes_home, iter_hermes_node_dirs home = os.path.expanduser("~") - hermes_home = os.path.expanduser(os.getenv("HERMES_HOME", os.path.join(home, ".hermes"))) # /usr/local/bin: canonical Node location (from-source Linux, Hermes Docker image, Intel Homebrew), # needed when a hand-authored env.PATH omits it — npx's shebang re-execs /usr/bin/env node. - candidates = (os.path.join(hermes_home, "node", "bin", command), os.path.join(home, ".local", "bin", command), - os.path.join(os.sep, "usr", "local", "bin", command)) + directories = [*map(str, iter_hermes_node_dirs(get_hermes_home())), os.path.join(home, ".local", "bin"), + os.path.join(os.sep, "usr", "local", "bin")] + candidates = (c for d in directories for c in _npx_bin_candidates(d, command, windows=windows)) return next((c for c in candidates if os.path.isfile(c) and os.access(c, os.X_OK)), command)