From 5a4d46234774883d5e282ce0036e64b801fe907c Mon Sep 17 00:00:00 2001 From: XiaoXiao0221 <263113677+XiaoXiao0221@users.noreply.github.com> Date: Mon, 13 Apr 2026 04:47:08 +0800 Subject: [PATCH 1/2] fix(security): add Windows archive detection and .zip extraction for tirith binary - Add Windows platform detection in _detect_target() (pc-windows-msvc) - Use .zip archive for Windows targets instead of .tar.gz - Implement zipfile extraction for Windows binary (tirith.exe) - Fix path resolution bug where src_base ignored nested zip members - Skip chmod +x on Windows (not supported) - Add unit test script for cross-platform archive handling Fixes: WinError 2 file not found when tirith auto-installs on Windows --- tools/test_tirith_security_fix.py | 191 ++++++++++++++++++++++++++++++ tools/tirith_security.py | 54 ++++++--- 2 files changed, 228 insertions(+), 17 deletions(-) create mode 100644 tools/test_tirith_security_fix.py diff --git a/tools/test_tirith_security_fix.py b/tools/test_tirith_security_fix.py new file mode 100644 index 0000000000000..aa2f9abcd9d30 --- /dev/null +++ b/tools/test_tirith_security_fix.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +""" +Test script for tirith_security.py Windows compatibility fix. + +Tests: +1. _detect_target() returns correct platform triple +2. _get_archive_name() uses .zip for Windows, .tar.gz otherwise +3. ZIP extraction path resolution is correct (src_base = src) +4. dest path uses correct binary name (tirith.exe vs tirith) + +Run from hermes-agent root: + python3 tools/test_tirith_security_fix.py +""" + +import os +import sys +import tempfile +import zipfile +import tarfile +import platform +import stat + +# Add tools dir to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "hermes-agent", "tools")) + +# Import the module functions we need to test +# We test via mock since tirith_security imports hermes paths +import importlib.util + +spec = importlib.util.spec_from_file_location( + "tirith_security", + os.path.join(os.path.dirname(__file__), "tirith_security.py") +) +ts = importlib.util.module_from_spec(spec) +spec.loader.exec_module(ts) + +def test_detect_target(): + """Test _detect_target() returns correct platform triple.""" + print("\n=== Test 1: _detect_target() ===") + + target = ts._detect_target() + system = platform.system() + + print(f" Detected: {target}") + print(f" Platform: {system}/{platform.machine()}") + + if system == "Windows": + # _detect_target maps AMD64 -> x86_64, ARM64 -> aarch64 + arch_map = {"amd64": "x86_64", "aarch64": "aarch64"} + arch = arch_map.get(platform.machine().lower(), platform.machine().lower()) + expected = f"{arch}-pc-windows-msvc" + assert target == expected, f"Expected {expected}, got {target}" + elif system == "Linux": + arch = "x86_64" if platform.machine().lower() in ("x86_64", "amd64") else "aarch64" + assert target == f"{arch}-unknown-linux-gnu", f"Unexpected target: {target}" + elif system == "Darwin": + arch = "x86_64" if platform.machine().lower() in ("x86_64", "amd64") else "aarch64" + assert target == f"{arch}-apple-darwin", f"Unexpected target: {target}" + + print(" ✅ PASS") + return target + + +def test_archive_name(target): + """Test archive name uses .zip for Windows, .tar.gz otherwise.""" + print("\n=== Test 2: Archive name ===") + + is_windows = target.endswith("-pc-windows-msvc") + archive_name = f"tirith-{target}.tar.gz" + if is_windows: + archive_name = f"tirith-{target}.zip" + + print(f" Target: {target}") + print(f" Archive: {archive_name}") + + if is_windows: + assert archive_name == f"tirith-{target}.zip", f"Windows should use .zip, got {archive_name}" + else: + assert archive_name == f"tirith-{target}.tar.gz", f"Linux/macOS should use .tar.gz, got {archive_name}" + + print(" ✅ PASS") + + +def test_zip_extraction_path(): + """Test ZIP extraction correctly resolves nested member paths.""" + print("\n=== Test 3: ZIP extraction path resolution ===") + + # Simulate a ZIP with nested path (common in GitHub releases) + with tempfile.TemporaryDirectory() as tmpdir: + zip_path = os.path.join(tmpdir, "test.zip") + + # Create a test ZIP with nested tirith.exe + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("tirith.exe", b"fake binary") + zf.writestr("nested/path/tirith.exe", b"fake binary nested") + + # Simulate the extraction logic from _install_tirith + extracted_path = None + with zipfile.ZipFile(zip_path, "r") as zf: + for member in zf.namelist(): + if member == "tirith.exe" or member.endswith("/tirith.exe"): + if ".." in member: + continue + zf.extract(member, tmpdir) + extracted_path = os.path.join(tmpdir, member) + break + + print(f" Extracted to: {extracted_path}") + assert extracted_path is not None, "Should find tirith.exe" + assert os.path.exists(extracted_path), f"Extracted file should exist: {extracted_path}" + + # Verify src_base should equal extracted_path, not a hardcoded path + src_base_wrong = os.path.join(tmpdir, "tirith.exe") + print(f" src_base (correct): {extracted_path}") + print(f" src_base (wrong): {src_base_wrong}") + + # If nested, wrong approach would fail + if "nested" in extracted_path: + assert extracted_path != src_base_wrong, "Should use actual extracted path" + print(" ✅ PASS (nested path handled correctly)") + else: + print(" ✅ PASS (flat path works with both)") + + +def test_dest_binary_name(): + """Test destination binary name is tirith.exe on Windows, tirith otherwise.""" + print("\n=== Test 4: Destination binary name ===") + + test_cases = [ + ("x86_64-pc-windows-msvc", "tirith.exe"), + ("aarch64-pc-windows-msvc", "tirith.exe"), + ("x86_64-unknown-linux-gnu", "tirith"), + ("aarch64-apple-darwin", "tirith"), + ] + + for target, expected in test_cases: + is_windows = target.endswith("-pc-windows-msvc") + dest = f"tirith.exe" if is_windows else "tirith" + print(f" {target} → {dest}") + assert dest == expected, f"{target} should give {expected}, got {dest}" + + print(" ✅ PASS") + + +def test_chmod_not_called_on_windows(): + """Test chmod is NOT called for Windows binaries.""" + print("\n=== Test 5: No chmod on Windows ===") + + # Simulate the logic from _install_tirith + test_cases = [ + ("x86_64-pc-windows-msvc", False), # is_windows = True, should NOT chmod + ("aarch64-pc-windows-msvc", False), + ("x86_64-unknown-linux-gnu", True), # is_windows = False, should chmod + ("aarch64-apple-darwin", True), + ] + + for target, should_chmod in test_cases: + is_windows = target.endswith("-pc-windows-msvc") + would_chmod = not is_windows # actual logic from code + + print(f" {target}: chmod={would_chmod} (expected: {should_chmod})") + assert would_chmod == should_chmod, f"{target}: chmod should be {should_chmod}, got {would_chmod}" + + print(" ✅ PASS") + + +if __name__ == "__main__": + print("=" * 60) + print("tirith_security.py Windows Compatibility Fix - Test Suite") + print("=" * 60) + + try: + target = test_detect_target() + test_archive_name(target) + test_zip_extraction_path() + test_dest_binary_name() + test_chmod_not_called_on_windows() + + print("\n" + "=" * 60) + print("✅ ALL TESTS PASSED") + print("=" * 60) + sys.exit(0) + + except AssertionError as e: + print(f"\n❌ TEST FAILED: {e}") + sys.exit(1) + except Exception as e: + print(f"\n❌ ERROR: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/tools/tirith_security.py b/tools/tirith_security.py index b3055944e333c..7849f9f99ba3a 100644 --- a/tools/tirith_security.py +++ b/tools/tirith_security.py @@ -190,6 +190,8 @@ def _detect_target() -> str | None: plat = "apple-darwin" elif system == "Linux": plat = "unknown-linux-gnu" + elif system == "Windows": + plat = "pc-windows-msvc" else: return None @@ -295,6 +297,9 @@ def _install_tirith(*, log_failures: bool = True) -> tuple[str | None, str]: return None, "unsupported_platform" archive_name = f"tirith-{target}.tar.gz" + is_windows = target.endswith("-pc-windows-msvc") + if is_windows: + archive_name = f"tirith-{target}.zip" base_url = f"https://github.com/{_REPO}/releases/latest/download" tmpdir = tempfile.mkdtemp(prefix="tirith-install-") @@ -345,23 +350,38 @@ def _install_tirith(*, log_failures: bool = True) -> tuple[str | None, str]: if not _verify_checksum(archive_path, checksums_path, archive_name): return None, "checksum_failed" - with tarfile.open(archive_path, "r:gz") as tar: - # Extract only the tirith binary (safety: reject paths with ..) - for member in tar.getmembers(): - if member.name == "tirith" or member.name.endswith("/tirith"): - if ".." in member.name: - continue - member.name = "tirith" - tar.extract(member, tmpdir) - break - else: - log("tirith binary not found in archive") - return None, "binary_not_in_archive" - - src = os.path.join(tmpdir, "tirith") - dest = os.path.join(_hermes_bin_dir(), "tirith") - shutil.move(src, dest) - os.chmod(dest, os.stat(dest).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + if is_windows: + import zipfile + with zipfile.ZipFile(archive_path, "r") as zf: + for member in zf.namelist(): + if member == "tirith.exe" or member.endswith("/tirith.exe"): + if ".." in member: + continue + zf.extract(member, tmpdir) + src = os.path.join(tmpdir, member) + break + else: + log("tirith binary not found in archive") + return None, "binary_not_in_archive" + src_base = src # reuse the path computed at extraction time + else: + with tarfile.open(archive_path, "r:gz") as tar: + for member in tar.getmembers(): + if member.name == "tirith" or member.name.endswith("/tirith"): + if ".." in member.name: + continue + member.name = "tirith" + tar.extract(member, tmpdir) + break + else: + log("tirith binary not found in archive") + return None, "binary_not_in_archive" + src_base = os.path.join(tmpdir, "tirith") + + dest = os.path.join(_hermes_bin_dir(), "tirith.exe" if is_windows else "tirith") + shutil.move(src_base, dest) + if not is_windows: + os.chmod(dest, os.stat(dest).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) verification = "cosign + SHA-256" if cosign_verified else "SHA-256 only" logger.info("tirith installed to %s (%s)", dest, verification) From 728d2609c7295f3bcde3270d61466716e980b2ea Mon Sep 17 00:00:00 2001 From: XiaoXiao0221 <263113677+XiaoXiao0221@users.noreply.github.com> Date: Mon, 13 Apr 2026 05:37:34 +0800 Subject: [PATCH 2/2] fix(security): complete Windows tirith install support --- tests/tools/test_tirith_security.py | 81 +++++++++++- tools/test_tirith_security_fix.py | 191 ---------------------------- tools/tirith_security.py | 28 +++- 3 files changed, 102 insertions(+), 198 deletions(-) delete mode 100644 tools/test_tirith_security_fix.py diff --git a/tests/tools/test_tirith_security.py b/tests/tools/test_tirith_security.py index 10a92e9b94099..b9d40062319d6 100644 --- a/tests/tools/test_tirith_security.py +++ b/tests/tools/test_tirith_security.py @@ -2,6 +2,7 @@ import json import os +from pathlib import Path import subprocess import time from unittest.mock import MagicMock, patch @@ -618,6 +619,35 @@ def test_install_proceeds_when_cosign_passes(self, mock_target, mock_dl, assert mock_checksum.called # reached SHA-256 step assert mock_cosign.called # cosign was invoked + @patch("tools.tirith_security.os.chmod") + @patch("tools.tirith_security.shutil.move") + @patch("tools.tirith_security._verify_checksum", return_value=True) + @patch("tools.tirith_security.shutil.which", return_value=None) + @patch("tools.tirith_security._download_file") + @patch("tools.tirith_security._detect_target", return_value="x86_64-pc-windows-msvc") + def test_install_windows_uses_zip_and_installs_exe(self, mock_target, mock_dl, + mock_which, mock_checksum, + mock_move, mock_chmod): + """Windows auto-install downloads .zip and installs tirith.exe.""" + from tools.tirith_security import _install_tirith + + mock_zip = MagicMock() + mock_zip.__enter__ = MagicMock(return_value=mock_zip) + mock_zip.__exit__ = MagicMock(return_value=False) + mock_zip.namelist.return_value = ["nested/path/tirith.exe"] + + with patch("zipfile.ZipFile", return_value=mock_zip), \ + patch("tools.tirith_security._hermes_bin_dir", return_value="C:\\fake-bin"): + path, reason = _install_tirith() + + assert reason == "" + assert path == os.path.join("C:\\fake-bin", "tirith.exe") + assert mock_dl.call_args_list[0].args[0].endswith(".zip") + moved_src, moved_dest = mock_move.call_args.args + assert os.path.normpath(moved_src).endswith(os.path.normpath(os.path.join("nested", "path", "tirith.exe"))) + assert moved_dest == os.path.join("C:\\fake-bin", "tirith.exe") + mock_chmod.assert_not_called() + # --------------------------------------------------------------------------- # Background install / non-blocking startup (P2) @@ -692,6 +722,27 @@ def test_resolve_picks_up_background_result(self): _tirith_mod._resolved_path = None + def test_background_install_picks_up_windows_hermes_bin_exe(self): + """Background install re-check finds tirith.exe on Windows.""" + from tools.tirith_security import _background_install + import tempfile + + tmpdir = tempfile.mkdtemp() + hermes_bin = os.path.join(tmpdir, "tirith.exe") + with open(hermes_bin, "w", encoding="utf-8") as f: + f.write("fake exe") + + _tirith_mod._resolved_path = None + _tirith_mod._install_failure_reason = "" + + with patch("tools.tirith_security.platform.system", return_value="Windows"), \ + patch("tools.tirith_security.shutil.which", return_value=None), \ + patch("tools.tirith_security._hermes_bin_dir", return_value=tmpdir): + _background_install(log_failures=False) + + assert _tirith_mod._resolved_path == hermes_bin + _tirith_mod._resolved_path = None + # --------------------------------------------------------------------------- # Disk failure marker persistence (P2) @@ -849,6 +900,29 @@ def test_install_failed_recovers_from_hermes_bin(self): _tirith_mod._resolved_path = None + def test_install_failed_recovers_from_windows_hermes_bin_exe(self): + """Windows installs under HERMES_HOME/bin/tirith.exe are picked up.""" + from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED + import tempfile + + tmpdir = tempfile.mkdtemp() + hermes_bin = os.path.join(tmpdir, "tirith.exe") + with open(hermes_bin, "w", encoding="utf-8") as f: + f.write("fake exe") + + _tirith_mod._resolved_path = _INSTALL_FAILED + + with patch("tools.tirith_security.platform.system", return_value="Windows"), \ + patch("tools.tirith_security.shutil.which", return_value=None), \ + patch("tools.tirith_security._hermes_bin_dir", return_value=tmpdir), \ + patch("tools.tirith_security._clear_install_failed") as mock_clear: + result = _resolve_tirith_path("tirith") + assert result == hermes_bin + assert _tirith_mod._resolved_path == hermes_bin + mock_clear.assert_called_once() + + _tirith_mod._resolved_path = None + def test_install_failed_skips_network_when_local_absent(self): """After _INSTALL_FAILED, if local checks fail, network is NOT retried.""" from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED @@ -988,7 +1062,7 @@ def test_failure_marker_respects_hermes_home(self): from tools.tirith_security import _failure_marker_path with patch.dict(os.environ, {"HERMES_HOME": "/custom/hermes"}): result = _failure_marker_path() - assert result == "/custom/hermes/.tirith-install-failed" + assert os.path.normpath(result) == os.path.normpath(os.path.join("/custom/hermes", ".tirith-install-failed")) def test_conftest_isolation_prevents_real_home_writes(self): """The conftest autouse fixture sets HERMES_HOME; verify it's active.""" @@ -999,8 +1073,9 @@ def test_conftest_isolation_prevents_real_home_writes(self): def test_get_hermes_home_fallback(self): """Without HERMES_HOME set, falls back to ~/.hermes.""" from tools.tirith_security import _get_hermes_home - with patch.dict(os.environ, {}, clear=True): + with patch.dict(os.environ, {}, clear=True), \ + patch("hermes_constants.Path.home", return_value=Path("C:/Users/tester")): # Remove HERMES_HOME entirely os.environ.pop("HERMES_HOME", None) result = _get_hermes_home() - assert result == os.path.join(os.path.expanduser("~"), ".hermes") + assert os.path.normpath(result) == os.path.normpath(os.path.join("C:/Users/tester", ".hermes")) diff --git a/tools/test_tirith_security_fix.py b/tools/test_tirith_security_fix.py deleted file mode 100644 index aa2f9abcd9d30..0000000000000 --- a/tools/test_tirith_security_fix.py +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for tirith_security.py Windows compatibility fix. - -Tests: -1. _detect_target() returns correct platform triple -2. _get_archive_name() uses .zip for Windows, .tar.gz otherwise -3. ZIP extraction path resolution is correct (src_base = src) -4. dest path uses correct binary name (tirith.exe vs tirith) - -Run from hermes-agent root: - python3 tools/test_tirith_security_fix.py -""" - -import os -import sys -import tempfile -import zipfile -import tarfile -import platform -import stat - -# Add tools dir to path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "hermes-agent", "tools")) - -# Import the module functions we need to test -# We test via mock since tirith_security imports hermes paths -import importlib.util - -spec = importlib.util.spec_from_file_location( - "tirith_security", - os.path.join(os.path.dirname(__file__), "tirith_security.py") -) -ts = importlib.util.module_from_spec(spec) -spec.loader.exec_module(ts) - -def test_detect_target(): - """Test _detect_target() returns correct platform triple.""" - print("\n=== Test 1: _detect_target() ===") - - target = ts._detect_target() - system = platform.system() - - print(f" Detected: {target}") - print(f" Platform: {system}/{platform.machine()}") - - if system == "Windows": - # _detect_target maps AMD64 -> x86_64, ARM64 -> aarch64 - arch_map = {"amd64": "x86_64", "aarch64": "aarch64"} - arch = arch_map.get(platform.machine().lower(), platform.machine().lower()) - expected = f"{arch}-pc-windows-msvc" - assert target == expected, f"Expected {expected}, got {target}" - elif system == "Linux": - arch = "x86_64" if platform.machine().lower() in ("x86_64", "amd64") else "aarch64" - assert target == f"{arch}-unknown-linux-gnu", f"Unexpected target: {target}" - elif system == "Darwin": - arch = "x86_64" if platform.machine().lower() in ("x86_64", "amd64") else "aarch64" - assert target == f"{arch}-apple-darwin", f"Unexpected target: {target}" - - print(" ✅ PASS") - return target - - -def test_archive_name(target): - """Test archive name uses .zip for Windows, .tar.gz otherwise.""" - print("\n=== Test 2: Archive name ===") - - is_windows = target.endswith("-pc-windows-msvc") - archive_name = f"tirith-{target}.tar.gz" - if is_windows: - archive_name = f"tirith-{target}.zip" - - print(f" Target: {target}") - print(f" Archive: {archive_name}") - - if is_windows: - assert archive_name == f"tirith-{target}.zip", f"Windows should use .zip, got {archive_name}" - else: - assert archive_name == f"tirith-{target}.tar.gz", f"Linux/macOS should use .tar.gz, got {archive_name}" - - print(" ✅ PASS") - - -def test_zip_extraction_path(): - """Test ZIP extraction correctly resolves nested member paths.""" - print("\n=== Test 3: ZIP extraction path resolution ===") - - # Simulate a ZIP with nested path (common in GitHub releases) - with tempfile.TemporaryDirectory() as tmpdir: - zip_path = os.path.join(tmpdir, "test.zip") - - # Create a test ZIP with nested tirith.exe - with zipfile.ZipFile(zip_path, "w") as zf: - zf.writestr("tirith.exe", b"fake binary") - zf.writestr("nested/path/tirith.exe", b"fake binary nested") - - # Simulate the extraction logic from _install_tirith - extracted_path = None - with zipfile.ZipFile(zip_path, "r") as zf: - for member in zf.namelist(): - if member == "tirith.exe" or member.endswith("/tirith.exe"): - if ".." in member: - continue - zf.extract(member, tmpdir) - extracted_path = os.path.join(tmpdir, member) - break - - print(f" Extracted to: {extracted_path}") - assert extracted_path is not None, "Should find tirith.exe" - assert os.path.exists(extracted_path), f"Extracted file should exist: {extracted_path}" - - # Verify src_base should equal extracted_path, not a hardcoded path - src_base_wrong = os.path.join(tmpdir, "tirith.exe") - print(f" src_base (correct): {extracted_path}") - print(f" src_base (wrong): {src_base_wrong}") - - # If nested, wrong approach would fail - if "nested" in extracted_path: - assert extracted_path != src_base_wrong, "Should use actual extracted path" - print(" ✅ PASS (nested path handled correctly)") - else: - print(" ✅ PASS (flat path works with both)") - - -def test_dest_binary_name(): - """Test destination binary name is tirith.exe on Windows, tirith otherwise.""" - print("\n=== Test 4: Destination binary name ===") - - test_cases = [ - ("x86_64-pc-windows-msvc", "tirith.exe"), - ("aarch64-pc-windows-msvc", "tirith.exe"), - ("x86_64-unknown-linux-gnu", "tirith"), - ("aarch64-apple-darwin", "tirith"), - ] - - for target, expected in test_cases: - is_windows = target.endswith("-pc-windows-msvc") - dest = f"tirith.exe" if is_windows else "tirith" - print(f" {target} → {dest}") - assert dest == expected, f"{target} should give {expected}, got {dest}" - - print(" ✅ PASS") - - -def test_chmod_not_called_on_windows(): - """Test chmod is NOT called for Windows binaries.""" - print("\n=== Test 5: No chmod on Windows ===") - - # Simulate the logic from _install_tirith - test_cases = [ - ("x86_64-pc-windows-msvc", False), # is_windows = True, should NOT chmod - ("aarch64-pc-windows-msvc", False), - ("x86_64-unknown-linux-gnu", True), # is_windows = False, should chmod - ("aarch64-apple-darwin", True), - ] - - for target, should_chmod in test_cases: - is_windows = target.endswith("-pc-windows-msvc") - would_chmod = not is_windows # actual logic from code - - print(f" {target}: chmod={would_chmod} (expected: {should_chmod})") - assert would_chmod == should_chmod, f"{target}: chmod should be {should_chmod}, got {would_chmod}" - - print(" ✅ PASS") - - -if __name__ == "__main__": - print("=" * 60) - print("tirith_security.py Windows Compatibility Fix - Test Suite") - print("=" * 60) - - try: - target = test_detect_target() - test_archive_name(target) - test_zip_extraction_path() - test_dest_binary_name() - test_chmod_not_called_on_windows() - - print("\n" + "=" * 60) - print("✅ ALL TESTS PASSED") - print("=" * 60) - sys.exit(0) - - except AssertionError as e: - print(f"\n❌ TEST FAILED: {e}") - sys.exit(1) - except Exception as e: - print(f"\n❌ ERROR: {e}") - import traceback - traceback.print_exc() - sys.exit(1) diff --git a/tools/tirith_security.py b/tools/tirith_security.py index 7849f9f99ba3a..45f888e7497d5 100644 --- a/tools/tirith_security.py +++ b/tools/tirith_security.py @@ -181,6 +181,26 @@ def _hermes_bin_dir() -> str: return d +def _bundled_tirith_candidates() -> list[str]: + """Return candidate installed binary paths under $HERMES_HOME/bin.""" + bin_dir = _hermes_bin_dir() + if platform.system() == "Windows": + return [ + os.path.join(bin_dir, "tirith.exe"), + os.path.join(bin_dir, "tirith"), + ] + return [os.path.join(bin_dir, "tirith")] + + +def _find_bundled_tirith() -> str | None: + """Return an installed tirith path from $HERMES_HOME/bin if present.""" + is_windows = platform.system() == "Windows" + for candidate in _bundled_tirith_candidates(): + if os.path.isfile(candidate) and (is_windows or os.access(candidate, os.X_OK)): + return candidate + return None + + def _detect_target() -> str | None: """Return the Rust target triple for the current platform, or None.""" system = platform.system() @@ -446,8 +466,8 @@ def _resolve_tirith_path(configured_path: str) -> str: _clear_install_failed() return found - hermes_bin = os.path.join(_hermes_bin_dir(), "tirith") - if os.path.isfile(hermes_bin) and os.access(hermes_bin, os.X_OK): + hermes_bin = _find_bundled_tirith() + if hermes_bin: _resolved_path = hermes_bin _install_failure_reason = "" _clear_install_failed() @@ -510,8 +530,8 @@ def _background_install(*, log_failures: bool = True): _install_failure_reason = "" return - hermes_bin = os.path.join(_hermes_bin_dir(), "tirith") - if os.path.isfile(hermes_bin) and os.access(hermes_bin, os.X_OK): + hermes_bin = _find_bundled_tirith() + if hermes_bin: _resolved_path = hermes_bin _install_failure_reason = "" return