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`. 6a031bf1b3 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 6a031bf1b3) 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.
This commit is contained in:
Teknium
2026-10-08 15:08:46 -07:00
parent cd522d80a2
commit eb699a158c
4 changed files with 89 additions and 32 deletions

View File

@@ -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

View File

@@ -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():

View File

@@ -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 <payload>/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()]

View File

@@ -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"])