diff --git a/hermes_cli/relaunch.py b/hermes_cli/relaunch.py index c254184fe9..a5a8431fbe 100644 --- a/hermes_cli/relaunch.py +++ b/hermes_cli/relaunch.py @@ -84,18 +84,34 @@ def resolve_hermes_bin() -> Optional[str]: 1. ``sys.argv[0]`` if it resolves to a real executable. 2. ``shutil.which("hermes")`` on PATH. 3. ``None`` → caller should fall back to ``python -m hermes_cli.main``. + + Windows note: ``os.access(path, os.X_OK)`` returns True for ``.py`` and + ``.pyc`` files on Windows (the OS treats anything listed in PATHEXT as + executable, and Python files are often registered there). But + ``subprocess.run([script.py, ...])`` can't actually execute a .py + directly — CreateProcessW needs a real .exe, not a script associated + with the Python launcher. On Windows we therefore skip the argv[0] + fast-path when it points at a .py file and fall through to either + ``hermes.exe`` on PATH or the ``sys.executable -m hermes_cli.main`` + fallback. """ argv0 = sys.argv[0] + _is_windows = sys.platform == "win32" + + def _is_python_script(p: str) -> bool: + return p.lower().endswith((".py", ".pyc")) # Absolute path to an executable (covers nix store, venv wrappers, etc.) if os.path.isabs(argv0) and os.path.isfile(argv0) and os.access(argv0, os.X_OK): - return argv0 + if not (_is_windows and _is_python_script(argv0)): + return argv0 # Relative path — resolve against CWD if not argv0.startswith("-") and os.path.isfile(argv0): abs_path = os.path.abspath(argv0) if os.access(abs_path, os.X_OK): - return abs_path + if not (_is_windows and _is_python_script(abs_path)): + return abs_path # PATH lookup path_bin = shutil.which("hermes") diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 13af38953b..2ffca87adb 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -963,9 +963,17 @@ function Install-NodeDeps { return } - # Resolve npm.cmd to an absolute path so PATHEXT doesn't bite us when - # the installer runs in a session that hasn't refreshed PATH since the - # Node.js install. Get-Command respects PATHEXT. + # Resolve npm explicitly to npm.cmd, NOT npm.ps1. Node.js on Windows + # ships BOTH npm.cmd (a batch shim) and npm.ps1 (a PowerShell shim). + # Get-Command's default ordering picks whichever comes first in PATHEXT, + # and on many systems that's .ps1 — but .ps1 requires scripts to be + # enabled in PowerShell's execution policy, which most Windows users + # don't have (the Restricted / RemoteSigned default blocks unsigned + # .ps1 files). .cmd has no such restriction and works on every box. + # + # Strategy: look next to the npm shim we found and prefer npm.cmd if + # it exists in the same directory. Fall back to whatever Get-Command + # returned if we can't find a .cmd sibling. $npmCmd = Get-Command npm -ErrorAction SilentlyContinue if (-not $npmCmd) { Write-Warn "npm not found on PATH — skipping Node.js dependencies." @@ -973,6 +981,16 @@ function Install-NodeDeps { return } $npmExe = $npmCmd.Source + if ($npmExe -like "*.ps1") { + $npmCmdSibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd" + if (Test-Path $npmCmdSibling) { + Write-Info "Using npm.cmd (PowerShell execution policy blocks npm.ps1)" + $npmExe = $npmCmdSibling + } else { + Write-Warn "Only npm.ps1 available — install may fail if script execution is disabled." + Write-Info " If it fails, either enable PS script execution or install Node via winget." + } + } # Helper: run "npm install" in a given directory and surface the real # error when it fails. Returns $true on success. diff --git a/tests/hermes_cli/test_relaunch.py b/tests/hermes_cli/test_relaunch.py index 4658c678fd..1b4f4ff154 100644 --- a/tests/hermes_cli/test_relaunch.py +++ b/tests/hermes_cli/test_relaunch.py @@ -229,4 +229,58 @@ class TestRelaunch: assert exc_info.value.code == 1 err = capsys.readouterr().err assert "relaunch failed" in err - assert "open a new terminal" in err.lower() or "path" in err.lower() \ No newline at end of file + assert "open a new terminal" in err.lower() or "path" in err.lower() + + +class TestResolveHermesBinWindowsPyGuard: + """On Windows, resolve_hermes_bin MUST NOT return a .py path. + os.access(x, os.X_OK) returns True for .py files on Windows because + PATHEXT includes .py when the Python launcher is installed — but + subprocess.run can't actually exec a .py directly, so the relaunch + would fail with the cryptic "%1 is not a valid Win32 application" error. + """ + + def test_windows_rejects_py_argv0_falls_through_to_path(self, monkeypatch, tmp_path): + """On Windows, if sys.argv[0] is a .py file, we must skip the + argv[0] fast-path and fall through to PATH / python -m.""" + # Build a fake .py script that "passes" the isfile + X_OK checks. + script = tmp_path / "main.py" + script.write_text("# stub") + + monkeypatch.setattr(relaunch_mod.sys, "platform", "win32") + monkeypatch.setattr(relaunch_mod.sys, "argv", [str(script), "chat"]) + # Force PATH lookup to return a hermes.exe so the test doesn't + # exercise the None-fallback path (that's a separate test). + monkeypatch.setattr( + relaunch_mod.shutil, "which", + lambda name: r"C:\venv\Scripts\hermes.exe" if name == "hermes" else None, + ) + + bin_path = relaunch_mod.resolve_hermes_bin() + # Must NOT be the .py — must be the hermes.exe PATH entry. + assert bin_path == r"C:\venv\Scripts\hermes.exe" + + def test_posix_still_accepts_py_argv0(self, monkeypatch, tmp_path): + """POSIX behaviour unchanged: argv[0] pointing at an executable + script (including .py with a shebang + chmod +x) is fine to return + because POSIX exec can route through the shebang line.""" + if sys.platform == "win32": + pytest.skip("POSIX semantics") + script = tmp_path / "hermes" + script.write_text("#!/usr/bin/env python3\n") + script.chmod(0o755) + monkeypatch.setattr(relaunch_mod.sys, "argv", [str(script), "chat"]) + assert relaunch_mod.resolve_hermes_bin() == str(script) + + def test_windows_py_argv0_with_no_hermes_on_path_returns_none(self, monkeypatch, tmp_path): + """Bulletproof fallback: if argv0 is .py on Windows AND hermes.exe + isn't on PATH, return None so the caller falls back to + python -m hermes_cli.main.""" + script = tmp_path / "main.py" + script.write_text("# stub") + + monkeypatch.setattr(relaunch_mod.sys, "platform", "win32") + monkeypatch.setattr(relaunch_mod.sys, "argv", [str(script), "chat"]) + monkeypatch.setattr(relaunch_mod.shutil, "which", lambda name: None) + + assert relaunch_mod.resolve_hermes_bin() is None