From b3859ec489d68dd21dbfefee82f640a790305f75 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Tue, 25 Aug 2026 21:47:03 +0000 Subject: [PATCH] fix(update): strip PyInstaller bootloader env vars when re-execing after self-update --- strix/interface/main.py | 4 ++-- strix/interface/update_check.py | 30 ++++++++++++++++++++++++++++++ tests/test_update_check.py | 27 +++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/strix/interface/main.py b/strix/interface/main.py index 45e114b5..96459978 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -6,7 +6,6 @@ Strix Agent Interface import argparse import asyncio import contextlib -import os import sys from pathlib import Path @@ -36,6 +35,7 @@ from strix.interface.update_check import ( is_binary_install, notify_update, prompt_update_if_available, + restart_after_update, start_background_check, ) from strix.interface.utils import ( @@ -440,7 +440,7 @@ def main() -> None: start_background_check() if not args.non_interactive and prompt_update_if_available(Console()): if is_binary_install() and sys.platform != "win32": - os.execv(sys.executable, sys.argv) # noqa: S606 # nosec B606 + restart_after_update() sys.exit(0) check_docker_installed() diff --git a/strix/interface/update_check.py b/strix/interface/update_check.py index f0d22ae6..9e98b38f 100644 --- a/strix/interface/update_check.py +++ b/strix/interface/update_check.py @@ -264,6 +264,36 @@ def prompt_update_if_available(console: Console) -> bool: return run_package_upgrade(console, method) +def restart_env() -> dict[str, str]: + """Environment for re-exec'ing the binary after a self-update. + + The PyInstaller bootloader marks its child process via environment + variables (``_MEIPASS2`` on older versions, ``_PYI_*`` on 6.x) that + point at the already-extracted archive of the *running* version. If + they leak into the re-exec'd process, the new binary skips extraction + and runs the old code, so the update never appears to take effect. + Library-path variables the bootloader overrode are restored from the + ``*_ORIG`` copies it saved. + """ + env = { + key: value + for key, value in os.environ.items() + if key != "_MEIPASS2" and not key.startswith("_PYI_") + } + for var in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH"): + orig = env.pop(f"{var}_ORIG", None) + if orig is not None: + env[var] = orig + elif var in os.environ: + env.pop(var, None) + return env + + +def restart_after_update() -> None: + """Replace the current process with the freshly updated binary.""" + os.execve(sys.executable, sys.argv, restart_env()) # noqa: S606 # nosec B606 + + def _release_target() -> str | None: raw_os = platform.system().lower() os_name = {"darwin": "macos", "linux": "linux", "windows": "windows"}.get(raw_os) diff --git a/tests/test_update_check.py b/tests/test_update_check.py index 3583f393..e67966d1 100644 --- a/tests/test_update_check.py +++ b/tests/test_update_check.py @@ -153,6 +153,33 @@ def test_self_update_already_latest(monkeypatch: pytest.MonkeyPatch) -> None: assert update_check.self_update() is True +def test_restart_env_strips_pyinstaller_vars(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("_MEIPASS2", "/stale/_MEIold") + monkeypatch.setenv("_PYI_APPLICATION_HOME_DIR", "/stale/_MEIold") + monkeypatch.setenv("_PYI_ARCHIVE_FILE", "/old/strix") + monkeypatch.setenv("_PYI_PARENT_PROCESS_LEVEL", "1") + monkeypatch.setenv("SOME_OTHER_VAR", "kept") + + env = update_check.restart_env() + + assert "SOME_OTHER_VAR" in env + assert "_MEIPASS2" not in env + assert not any(key.startswith("_PYI_") for key in env) + + +def test_restart_env_restores_library_paths(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LD_LIBRARY_PATH", "/stale/_MEIold/lib") + monkeypatch.setenv("LD_LIBRARY_PATH_ORIG", "/usr/lib/custom") + monkeypatch.setenv("DYLD_LIBRARY_PATH", "/stale/_MEIold/lib") + monkeypatch.delenv("DYLD_LIBRARY_PATH_ORIG", raising=False) + + env = update_check.restart_env() + + assert env["LD_LIBRARY_PATH"] == "/usr/lib/custom" + assert "LD_LIBRARY_PATH_ORIG" not in env + assert "DYLD_LIBRARY_PATH" not in env + + def test_sha256_file(tmp_path: Path) -> None: path = tmp_path / "blob" path.write_bytes(b"strix")