From eb699a158cfbcbd05e53b25b26ed1efbb6ef60b0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:08:46 -0700 Subject: [PATCH] fix(pm): MSIX plugin installs build on a writable copy of the bundled Python (#135236) Enabling a plugin with Python dependencies, and `hermes pm install`, failed in the packaged Windows app: uv lock/sync/pip check exit 101 with "Unable to create process using ...\venv\Scripts\python.exe". uv queries a venv by running its Scripts\python.exe. That redirector lives outside the package and CreateProcess's the interpreter named by pyvenv.cfg `home`. 6a031bf1b3f moved generations onto the packaged interpreter, so `home` names a file under WindowsApps that a process without package identity may not start (exit 101 is the redirector's RC_CREATE_PROCESS). Restore the verified writable copy of the pinned Python in the user store as the build interpreter for sealed Windows payloads. venv_command (also from 6a031bf1b3f) stays: Hermes still enters those venvs on the packaged interpreter, so its own children keep package access to the bundled git, rg and PM worker. --- apps/desktop/BUILDING.md | 16 ++++++----- pm/_uv.py | 34 +++++++++++++++++++----- pm/environments.py | 17 ++++++------ tests/pm/test_uv_python.py | 54 ++++++++++++++++++++++++++++++-------- 4 files changed, 89 insertions(+), 32 deletions(-) diff --git a/apps/desktop/BUILDING.md b/apps/desktop/BUILDING.md index ed242a35d2..e00be53aa9 100644 --- a/apps/desktop/BUILDING.md +++ b/apps/desktop/BUILDING.md @@ -319,15 +319,17 @@ relaunch waiter is registered before the install request. Unknown checks, cancellation and request failures do not count as successful updates. Native acceptance requires a Store-acquired package or flight, not a sideloaded MSIX. -On Windows bundles, every dependency generation is built on the bundled Python -and nothing runs a venv's own executables. A venv's `Scripts\python.exe` is a -redirector outside the package that starts the packaged interpreter, which -Windows refuses (WinError 5) to a process without package identity. The app and -execution aliases keep their signed bundled launchers, and children that need a +On Windows bundles, PM builds dependency generations on a verified writable +copy of the bundled Python in its user store. A venv's `Scripts\python.exe` is a +redirector outside the package. Windows refuses it (WinError 5) when it starts the +packaged interpreter, and uv runs that redirector during every lock, sync and +`pip check`. Hermes never runs a venv's own executables. The app and execution +aliases keep their signed bundled launchers, and children that need a generation's packages start through `pm.environments.venv_command`: the bundled Python with `-S` plus `pm/_venv_entry.py`, which attaches the generation's -site-packages. The new venv's generated console scripts must not replace the -launchers, and are kept off `PATH`. +site-packages. Processes started from the bundled Python keep package access to +the bundled git, rg and the PM worker. The new venv's generated console scripts +must not replace the launchers, and are kept off `PATH`. Sideload stable versions are `X.Y.Z.0`. Canary revisions derive from elapsed minutes after the stable baseline. The release script rejects ambiguous or diff --git a/pm/_uv.py b/pm/_uv.py index 85180f03c0..8ef4d015fb 100644 --- a/pm/_uv.py +++ b/pm/_uv.py @@ -3,20 +3,27 @@ from __future__ import annotations from pathlib import Path -from pm.filesystem import native +from pm import paths +from pm.filesystem import long_root, native +from pm.lock import Facts from pm.package import InstallError from pm.registry import get_package -from pm.store import current_target +from pm.store import Store, current_target def _toolchain(*, realize: bool = True, explicit: bool = False) -> tuple[Path, Path] | None: """Resolve uv and Python without host discovery or recursive worker dispatch. - A read-only probe never installs. A sealed payload builds on its own - interpreter: a venv's redirectors then name a path inside the package, so - nothing may run them (``pm.environments.venv_command`` enters such a venv). + A read-only probe never installs. A sealed Windows payload builds on a verified writable + copy of its Python: uv starts a venv's ``Scripts\\python.exe`` to query it, and that + redirector runs outside the package, so its target must too. A redirector naming the + packaged interpreter dies with exit 101 (#135236). Hermes itself still enters those venvs + on the packaged interpreter (``pm.environments.venv_command``). """ - from pm.install import _installed_location, _lockfile, ensure + from pm.install import ( + _install, _installed_location, _lockfile, _refuse_lazy, + ensure, lazy_installs_allowed, sealed, + ) if realize: ensure("uv", explicit=explicit) @@ -31,6 +38,21 @@ def _toolchain(*, realize: bool = True, explicit: bool = False) -> tuple[Path, P if not realize: return None raise InstallError(name, "pinned tool is unavailable", "run `hermes pm install`") + if name == "python" and target.startswith("win32") and sealed(): + # Spelled like Store spells its root, so the comparison and the copy's file calls agree. + writable = long_root(paths.writable_store_root()) + if location[1].root != writable: + copied = _installed_location(package, lockfile, target, verify=explicit, roots=(writable,)) + if copied is None: + if not realize: + return None + if not explicit and not lazy_installs_allowed(): + raise _refuse_lazy(name, "a writable Python is required for bundled builds") + copy_store = Store(writable) + copy_facts = Facts(writable / "facts.json") + _install(package, lockfile, copy_facts, copy_store, target, copy_from=location) + copied = copy_facts, copy_store + location = copied facts, store = location binary = package.binary(store.entry(facts.get(name)["entry"]), target) if binary is None or not binary.is_file(): diff --git a/pm/environments.py b/pm/environments.py index 59ef94036c..7121c279e5 100644 --- a/pm/environments.py +++ b/pm/environments.py @@ -352,12 +352,13 @@ def _payload_store_python(root: Path) -> Path | None: def venv_command(project_root: Path, venv: Path, options: tuple[str, ...] | list[str] = ()) -> list[str]: """The argv prefix that runs Python inside *venv*; append a script, ``-c`` or ``-m``. - A sealed payload never enters a venv through ``Scripts\\python.exe``. That redirector is - an executable outside the package that starts the package's interpreter, which an MSIX - refuses (WinError 5) to a process without package identity. Instead the payload's own - interpreter attaches the venv's site-packages (``pm/_venv_entry.py``): the same - contract as the payload's launchers and its PM worker. Elsewhere the venv's - interpreter runs as is. *options* are interpreter flags (``-I``, ``-u``, ``-X …``). + A sealed payload never enters a venv through ``Scripts\\python.exe``. That redirector sits + outside the package and starts PM's writable copy of the Python (``pm._uv._toolchain``), + and a process on that copy has no package identity, so Windows refuses it the package's + git, rg and PM worker (WinError 5). Instead the payload's own interpreter attaches the + venv's site-packages (``pm/_venv_entry.py``): the same contract as the payload's + launchers and its PM worker. Elsewhere the venv's interpreter runs as is. *options* are + interpreter flags (``-I``, ``-u``, ``-X …``). """ root = Path(project_root).resolve() store_python = _payload_store_python(root) @@ -502,8 +503,8 @@ def activate_dependencies(project_root: Path) -> None: # A sealed payload's launchers live in /bin instead. directories = [payload_command_dir(project_root) or project_root.resolve() / ".hermes" / "bin"] # A sealed payload's Windows venv redirectors never go on PATH: a shipped venv's still - # names the build machine, and a generation built since names the payload's interpreter, - # which an MSIX refuses to an outside executable (see venv_command). + # names the build machine, and a generation built since names PM's writable Python copy, + # which cannot start the package's executables (see venv_command). if not (os.name == "nt" and _payload_manifest(project_root.resolve()) is not None): directories.append(venv_bin_dir(environment)) prefix = [str(path) for path in directories if path.is_dir()] diff --git a/tests/pm/test_uv_python.py b/tests/pm/test_uv_python.py index f818e38f7f..3d7c4e9e93 100644 --- a/tests/pm/test_uv_python.py +++ b/tests/pm/test_uv_python.py @@ -180,42 +180,74 @@ def test_uv_refuses_discovery_when_pm_python_is_missing(installed_uv, monkeypatc assert not list(entry.iterdir()) -def test_bundled_uv_builds_on_the_shipped_python_and_writes_nothing(installed_uv, monkeypatch): - """A sealed payload's venvs are built on its own interpreter, never a writable copy: - the venvs are entered through ``venv_command``, so no redirector needs an outside target.""" +@pytest.mark.platforms("windows") +def test_bundled_uv_uses_a_verified_writable_python_without_changing_runtime(installed_uv, monkeypatch): from pm import paths from pm import registry from pm.store import Store, tree_digest root, uv_binary, facts, target, digest = installed_uv + ensure = importlib.import_module("pm.install") shipped = uv_binary.parent.parent writable = root / "writable-tools" monkeypatch.setattr(paths, "writable_store_root", lambda: writable) (shipped.parent / "manifest.json").write_text("{}", encoding="utf-8") + class FixturePython(Python): + def verify(self, entry, target): + return "" if self.binary(entry, target).read_bytes() == b"pinned interpreter" else "damaged Python" + class FixtureUv(Uv): emulated_arch_targets = {target} monkeypatch.setitem(registry._packages, "uv", FixtureUv()) facts.record("uv", "test", uv_binary.parent.name, {}, shipped, target=target, artifacts=[digest], digest=tree_digest(uv_binary.parent)) - python = Python() + python = FixturePython() monkeypatch.setitem(registry._packages, "python", python) entry = shipped / python.store_entry("test", target) - binary = python.binary(entry, target) - binary.parent.mkdir(parents=True) + entry.mkdir() + binary = entry / "python.exe" binary.write_bytes(b"pinned interpreter") - binary.chmod(0o755) + (entry / "python.dll").write_bytes(b"pinned runtime") facts.record("python", "test", entry.name, python.env(entry, target), shipped, target=target, artifacts=[digest], digest=tree_digest(entry)) before = facts.path.read_bytes() - monkeypatch.setattr(Store, "fetch_many", lambda *a, **k: pytest.fail("the shipped Python must not be fetched")) - monkeypatch.setattr(shutil, "copytree", lambda *a, **k: pytest.fail("the shipped Python must not be copied")) + shipped_digest = tree_digest(entry) + monkeypatch.setattr(ensure, "lazy_installs_allowed", lambda: False) - assert _toolchain(realize=False) == (uv_binary, binary) - assert _toolchain(explicit=True) == (uv_binary, binary) + def no_download(*args, **kwargs): + raise AssertionError("the verified Python must be copied without downloading") + + monkeypatch.setattr(Store, "fetch_many", no_download) + assert _toolchain(realize=False) is None assert not writable.exists() + with pytest.raises(InstallError, match="lazy installs are disabled"): + _toolchain() + assert not writable.exists() + + resolved_uv, python = _toolchain(explicit=True) + copied = writable / entry.name + assert resolved_uv == uv_binary + assert python == copied / "python.exe" + assert tree_digest(copied) == shipped_digest + copied_fact = Facts(writable / "facts.json").get("python") + assert copied_fact["digest"] == shipped_digest + assert copied_fact["artifacts"] == [digest] + assert copied_fact["target"] == target + assert ensure.installed_package("python").binary == binary + assert str(copied) not in ensure.env_for("python")["PATH"] + + def no_copy(*args, **kwargs): + raise AssertionError("a matching copy must be reused") + + with monkeypatch.context() as reuse: + reuse.setattr(shutil, "copytree", no_copy) + assert _toolchain(realize=False)[1] == python + assert _toolchain()[1] == python + assert facts.path.read_bytes() == before + assert tree_digest(entry) == shipped_digest @pytest.mark.parametrize("damage", [None, "source", "copy", "publication"])