From b7d6a06f456ae0a06e26bf0398c79d0bba68c99d Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 1 Sep 2026 09:27:52 -0700 Subject: [PATCH] Supply a Real python3 on Windows and Remove the Alias Stubs python3 did not reach the managed interpreter on a Windows host. It landed in one of three states and two of them were wrong: a Windows app execution alias stub, which can only offer to open the Microsoft Store, or a foreign interpreter earlier on PATH, which runs cleanly as the wrong Python and announces nothing. The second is the state that actually bit, and it also made scripts/host_gate.py grade MSYS2's 3.14.5 rather than the managed 3.13. install-tools.ps1 now repairs both when it manages the python tool. It removes the two alias stubs, guarded on a zero-byte file carrying reparse tag 0x8000001b so a real executable in that directory is never deleted, and it copies a real python3.exe beside the interpreter winget installed. The line is resolved from whatever version winget reports for the managed package id, through that line's PEP 514 registry key, so nothing in the repair names a version and it follows the pin when the pin moves. The repair runs after the package settles on every apply path, not only where winget did work, because a stub returns on a host whose python has been current for months and a reinstall removes the interpreter the copy sits beside. The gate keeps py -3 as its first probe, which is now the only place that name survives. python3 is the fleet's name everywhere a person or an agent reads one, but the gate measures the host, and py is the only name immune to both an activated virtual environment and a foreign python3. Two facts were measured rather than assumed, and both corrected prose that had been written from the issue's premise. The stub fails loudly, on stderr with exit 9009, so it never produced a false pass. And removing the stub is not the same act as the Settings toggle, which keeps reporting On afterwards, so the alias can return; TODO.md carries that as a measurement pending a logout and a reboot. Closes #1161 --- TODO.md | 16 ++ docs/host-setup.md | 21 +- host-setup/windows/README.md | 1 + host-setup/windows/install-tools.ps1 | 298 ++++++++++++++++++++++++++- scripts/tests/test_bootstrap.py | 39 ++++ spec/host-tools.json | 6 +- 6 files changed, 364 insertions(+), 17 deletions(-) diff --git a/TODO.md b/TODO.md index 703502d5..0b9fdcf5 100644 --- a/TODO.md +++ b/TODO.md @@ -370,6 +370,20 @@ Three findings raised while writing [`host-setup/windows/`][host-setup-windows], - **Settled** - The change is four `usage()` heredocs and four `parse_args()` bodies now that `bootstrap.sh` carries the same contract, and taking it deletes the differences-table row in [`host-setup/windows/README.md`][host-setup-windows] rather than leaving a permanent divergence. - **Open** - Nothing. +### Whether a Removed App Execution Alias Comes Back + +Removing the `python.exe` and `python3.exe` app execution alias stubs frees the name, which is measured. What is not measured is whether Windows puts them back. The Settings page keeps reporting both aliases as `On` after the files are gone, so the declared state and the on-disk state diverge, and nothing found so far says which one servicing reads. This needs a human to log out and back in, and to reboot, and to report whether the enabled copies reappear in the WindowsApps directory. + +**State** `measure`. **Touches** [`host-setup/windows/install-tools.ps1`][install-tools-ps1] and [`docs/host-setup.md`][host-setup-doc], both of which currently say the alias *can* return rather than that it does. **Cost** one logout and one reboot on a Windows host, then a one-line hub edit either way. + +- **Log out and back in, then reboot, and report whether the enabled alias copies return.** The check is whether `python.exe` and `python3.exe` exist again directly under the WindowsApps directory, which is the copy that sits on `PATH`. The package's own copies under the App Installer subdirectory are always present and are not the answer. + - **Blocked by** - Nothing, beyond the maintainer having a moment to log out and reboot. + - **Issue** - [#1161][issue-1161], which this cluster's own change closes. This measurement is what remains after it. + - **Checked** - Branch `python3-on-windows` on 2026-09-01, where the aliases were toggled on from Settings, removed by `install-tools.ps1 -Install python`, confirmed gone from the WindowsApps directory, and confirmed still reported `On` by the Settings page after that page was closed and reopened. No logout or reboot has followed, so recreation is untested rather than ruled out. + - **Settled** - Removing the file is the only mechanism available. The store the Settings toggle reads was searched for under `AppModel\SystemAppData` and the `AppModel` repository and was not found, and writing a registry location nobody has identified is the guessing the write-safety rules already forbid. + - **Settled** - The installer re-removes on every apply rather than assuming a host stays fixed, so recreation is self-healing on the next run either way, and turning the two toggles off in Settings is the durable fix a person can apply. + - **Open** - Whether the prose drops to "does not return" or hardens to "returns on every logon", which is the one thing the measurement decides. + ### Neither Host Bootstrap Has Run Against a Truly Fresh Host Two loaders exist so a copy-paste snippet takes a stock OS install to a configured dev host, and neither has ever been run that way. Everything either has behind it is a dry run or a read against an already-configured checkout, on a machine carrying most of the target tools already. That confirms the logic is internally consistent. It confirms nothing about a `winget` package id still resolving, a stock Debian netinst actually lacking `curl` the way the docs assume, `tar.exe` genuinely shipping on a given Windows image, or the interactive menu reading correctly on a real console. This needs a human watching a real run on a real fresh image and reporting back what broke, including anything that merely looked fine, since neither of those closes from a description of the logic. @@ -526,6 +540,7 @@ Regenerate [reports/divergences.md][divergences-report] before using it as the w [issue-673]: https://github.com/ptr727/ProjectTemplate/issues/673 [issue-767]: https://github.com/ptr727/ProjectTemplate/issues/767 [issue-931]: https://github.com/ptr727/ProjectTemplate/issues/931 +[issue-1161]: https://github.com/ptr727/ProjectTemplate/issues/1161 @@ -554,6 +569,7 @@ Regenerate [reports/divergences.md][divergences-report] before using it as the w [host-setup-doc]: ./docs/host-setup.md [host-setup-windows]: ./host-setup/windows/ [install-tools]: ./host-setup/linux/install-tools.sh +[install-tools-ps1]: ./host-setup/windows/install-tools.ps1 [line-endings-ref]: ./.agents/skills/comment-and-doc-style/references/line-endings.md [markdownlint]: ./.markdownlint-cli2.jsonc [matrix]: ./reports/conformance-matrix.md diff --git a/docs/host-setup.md b/docs/host-setup.md index f1febfb7..5f8eae9e 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -18,7 +18,7 @@ This section is the **contract**: which tools a host needs and which repo proced | --- | --- | --- | --- | | `git` | everything, and the identity and signing contract in [`STANDUP.md`][standup] step 0 | `git --version` | none | | `gh` | the PR and review loop, `gh api` queries, `repo-config/configure.sh` | `gh --version` | **2.47.0**, measured | -| Python 3 | `scripts/` and `spec/` (standard library only, no packages to install) | `python3 --version`, or `py -3 --version` on native Windows | **3.13**, target | +| Python 3 | `scripts/` and `spec/` (standard library only, no packages to install) | `python3 --version` | **3.13**, target | | `jq` | the ruleset normalizer in `repo-config/configure.sh`, the ruleset diff in [`AUDIT.md`][audit] section 6, and payload regeneration | `jq --version` | **1.7**, target | | Ripgrep | repository searches and file discovery by coding agents | `rg --version` | **13.0.0**, target | | `docker` | the four linters, which run as pinned images rather than local installs | `docker --version` | **29.6.2**, target | @@ -28,13 +28,17 @@ The **Floor** column exists because presence and sufficiency are different quest Two consequences worth reading off the table rather than discovering later. **Python 3 needs no packages**, because every script here is standard library only, so a bare interpreter is enough. And **the linters need only `docker`**, not `node`, `dotnet` or a local `markdownlint`, since each runs as a pinned image, which is what keeps a local run and CI the same check. -**The interpreter is not called `python3` everywhere.** On native Windows the installer registers `python`, `py` and `python3.13` but **not** `python3`, where that name instead resolves to a Microsoft Store alias stub that reports the interpreter as missing, so a correctly set-up host fails a `python3` check. Stock Debian is the mirror image, carrying `python3` and no bare `python`. Use `py -3` on native Windows and `python3` elsewhere, and prefer `python3` in any script that must run on both, since WSL2 shadows the Windows stub. +**The interpreter is called `python3` everywhere, and on native Windows that is something the tooling provides rather than something the platform ships.** Windows registers `python` and `py` through the interpreter's own installer, and gives `python3` by default to an *app execution alias stub*, a zero-byte reparse point in the WindowsApps directory belonging to App Installer, which only ever offers to open the Microsoft Store. A Microsoft Store Python registers aliases under those same two names carrying the same reparse tag, and those reach a real interpreter somebody chose, so the target package rather than the tag is what tells a placeholder from a working setup. The installer removes only the placeholder and reports anything else. Measured on a fleet Windows host, that stub writes its `Python was not found` message to **stderr** and exits **9009**, so it fails loudly and a `&&` chain does stop on it. What it does instead is occupy the name: WindowsApps is on `PATH` by default, so wherever it sits ahead of the install directory the stub answers and the installed interpreter cannot be reached under that name at all. [`host-setup/windows/install-tools.ps1`][install-tools-windows] fixes both halves when it manages the `python` tool: it removes those stubs, and it puts a real `python3` beside the interpreter it installed. Stock Debian is the mirror image, carrying `python3` and no bare `python`, so `python3` is the one name that is correct on all three platforms and is what every script and brief should use. + +**Removing a stub is not the same act as the Settings toggle, and the toggle keeps reading `On` afterwards.** The alias has two layers: the package's own copy under the App Installer subdirectory of WindowsApps, which `winget.exe` sits beside and which the installer never touches, and the enabled copy in WindowsApps itself, which is the one on `PATH` and the one the installer removes. Removing it frees the name immediately, measured rather than assumed. But the store the Settings page reads is not that file and no documented interface writes it, so the page still shows `On`, and the alias can return when App Installer is next serviced. `install-tools.ps1` re-removes it on every apply rather than assuming a host stays fixed, and turning the two toggles off in Settings is what stops it coming back at all. + +**Two further Windows states survive that and are reported rather than fixed.** A `python3` from MSYS2, Cygwin or Scoop earlier on `PATH` is a real interpreter that answers cleanly, so it is the wrong one silently rather than loudly. `install-tools.ps1 -Report python` names it, and `PATH` order is the only thing deciding it. And an activated virtual environment on Windows carries `python.exe` but no `python3.exe`, so a bare `python3` there reaches the host interpreter rather than the environment's. Use `python` inside an environment, which is the interpreter an environment puts first on purpose. A missing tool is a host gap, not a repo problem. Install it and re-run, rather than working around it in a repo. ### Where a Tool Comes From, and How Old It May Be -Presence is the weaker half of this contract. Both host defects this fleet has actually hit are **version** facts on a tool that is installed, answers `--version`, and looks healthy, so the table above cannot see either one. [`spec/host-tools.json`][host-tools] carries the floors as data and records the defect each one encodes, and [`scripts/host_gate.py`][host-gate] reads it. A floor is one of two kinds and names its own kind in the `why` it carries. A **measured** floor sits above a version known to break a documented procedure, which is what both `gh` and `git-restore-mtime` carry. A **target** floor names the version the repo's own toolchain is configured for, which is what `python3` carries at 3.13, where `pyproject.toml` sets ruff and mypy to that version, so a lower interpreter is unverified rather than known broken and the entry says exactly that. Everything else carries none, deliberately: a floor nobody can justify becomes a host failure nobody can act on. +Presence is the weaker half of this contract. Both host defects this fleet has actually hit are **version** facts on a tool that is installed, answers `--version`, and looks healthy, so the table above cannot see either one. [`spec/host-tools.json`][host-tools] carries the floors as data and records the defect each one encodes, and [`scripts/host_gate.py`][host-gate] reads it. A floor is one of two kinds and names its own kind in the `why` it carries. A **measured** floor sits above a version known to break a documented procedure, which is what both `gh` and `git-restore-mtime` carry. A **target** floor names the version the repo's own toolchain is configured for, which is what `python3` carries at 3.13, where `pyproject.toml` sets ruff and mypy to that version, so a lower interpreter is unverified rather than known broken and the entry says exactly that. Either kind is a floor and not a ceiling: the platform rather than this repo decides how high a host can go, since a Linux distribution ships the line it ships, so a host at the floor and a host above it both pass. Everything else carries none, deliberately: a floor nobody can justify becomes a host failure nobody can act on. **`gh` must not come from the distribution's package on Linux.** This is the one place this document names a source, because here the source *is* the requirement rather than a convenience. The GitHub CLI maintainers state that the community-distributed `2.45.x` / `2.46.x` is **broken by deprecated GitHub APIs**, so install from the official apt repository at [cli.github.com][cli-install-link] and upgrade from there. Both `gh` limitations recorded in [`OPERATIONS.md`][operations] were observed on a host carrying a distribution `gh 2.46.0`, and both are the deprecation class that note describes. On **Windows** `winget` tracks upstream releases, and on macOS Homebrew does, so neither raises this hazard and neither needs a note of its own. @@ -306,11 +310,11 @@ gh auth status The gate replaced a line that ran `--version` on each tool and read only whether it answered. That form reported a host carrying the broken `gh` as fully set up, which is the failure it exists to stop. It exits non-zero on a missing required tool or one below its floor, and a below-floor finding prints the defect behind the floor rather than the number alone, names where to install from, and prints the command that installs or upgrades the tool on the current platform, so that failure carries its own fix. A missing tool prints the one-line fact, and [`host-setup/`][host-setup-dir] is its remedy. -**This block is POSIX, and on native Windows two lines need translating.** Run the POSIX form from WSL2 or Git Bash per the shell note, or use the PowerShell form below. Git Bash inherits the Windows `PATH`, so `python3` reaches the same Store alias stub it does in PowerShell and reports a working interpreter as missing. +**This block is POSIX, and on native Windows one line needs translating.** Run the POSIX form from WSL2 or Git Bash per the shell note, or use the PowerShell form below. Git Bash inherits the Windows `PATH`, so `python3` reaches the same interpreter there that it does in PowerShell, which is the point of [`install-tools.ps1`][install-tools-windows] supplying that name in the first place. ```powershell -py -3 scripts/host_gate.py # presence and version floors, from spec/host-tools.json -py -3 scripts/skills_install.py --report # the skills install stamp is current +python3 scripts/host_gate.py # presence and version floors, from spec/host-tools.json +python3 scripts/skills_install.py --report # the skills install stamp is current git config --global --list | Select-String "user\.|signing|gpg\." $d = Join-Path $env:TEMP ([guid]::NewGuid()) try { @@ -331,9 +335,9 @@ try { gh auth status ``` -Verified on Windows 11 Pro 10.0.26200 with PowerShell 7.6.4, where `py -3 scripts/host_gate.py` exits 0 over seven declared tools. It was supplied under [#483][issue-483], which had deferred it until somebody had executed it on a Windows host. Only two lines differ from the POSIX block: the interpreter, and the filter, because `grep` has no Windows peer and `Select-String` is the one that ships. +Verified on Windows 11 Pro 10.0.26200 with PowerShell 7.6.4, where `python3 scripts/host_gate.py` exits 0 over eight declared tools. It was supplied under [#483][issue-483], which had deferred it until somebody had executed it on a Windows host. One line differs from the POSIX block, the filter, because `grep` has no Windows peer and `Select-String` is the one that ships. -**`py -3` rather than `python`, and the reason is not only the Store stub.** Both names reach the same interpreter on a correctly set-up host, so the stub rules out `python3` and chooses nothing between the other two. What chooses is that an activated virtual environment puts its own interpreter first, so `python` resolves to that environment's. That is right for running project code and wrong here, because this gate measures **the host's** interpreter against a floor, and run as `python` from an activated environment it grades the environment instead. `py` is the launcher and reaches a registered system interpreter whatever is active. The prescription is therefore narrow: `py -3` for this gate, and `python` for everything else. +**The gate probes `py -3` before `python3`, which is the one place that name survives.** Which interpreter *runs* `host_gate.py` does not decide what it measures, because the gate reads its answer from its own probes rather than from itself. What those probes must avoid is grading something other than the host: a bare `python3` is reached through `PATH`, where an activated virtual environment or a foreign interpreter from MSYS2, Cygwin or Scoop answers ahead of the managed one. `py` is the launcher, reaches a registered system interpreter whatever is active, and exists on no other platform, so it answers first on Windows and not at all on Linux or macOS, which leaves the `python3` probe to answer for those. The prescription is therefore narrow and internal: `py -3` is the gate's first probe, and `python3` is the name everything else uses. **What the host can do once this passes**, which is the point of the contract above: @@ -368,6 +372,7 @@ A host that fails any row is not ready for the procedure that row names, and the [host-setup-dir]: ../host-setup/ [host-setup-windows]: ../host-setup/windows/ [host-tools]: ../spec/host-tools.json +[install-tools-windows]: ../host-setup/windows/install-tools.ps1 [issue-483]: https://github.com/ptr727/ProjectTemplate/issues/483 [issue-781]: https://github.com/ptr727/ProjectTemplate/issues/781 [operations]: ../OPERATIONS.md diff --git a/host-setup/windows/README.md b/host-setup/windows/README.md index 702796a9..7a1ad900 100644 --- a/host-setup/windows/README.md +++ b/host-setup/windows/README.md @@ -110,6 +110,7 @@ This moved a boundary the tool used to hold: WSL used to be read-only here, and | `sudo` re-runs a command as root | nothing elevates | `winget` raises UAC per installer, which is the path with the fewest failures | | `install-tools.sh --sudo-timestamp` shares one sudo credential cache across a user's terminals | no peer | Nothing here elevates on Windows, so there is no cached credential to share | | `unmanaged` means the upstream repository is unconfigured | `unmanaged` means the tool is on `PATH` and winget knows no package for it | The same question, by a different mechanism | +| `python-is-python3` supplies the `python` name, under `--optional` | `install-tools.ps1` supplies the `python3` name itself, always | Debian packages the alias and Windows does not, so the one platform installs a package and the other writes the file. This is the only action here that deletes something winget did not install, so it is narrow by construction: it removes an app execution alias only where the reparse tag and the target package both say it is the Microsoft Store placeholder, and reports rather than removes anything else | | `credential.helper cache --timeout=3600` | `credential.helper manager`, and only where unset | Git Credential Manager ships with Git for Windows | | `ssh-agent` is a socket, started per shell | `ssh-agent` is a Windows service, reported and not started | Starting it needs administrator, and nothing here elevates | | no WSL script | `setup-wsl.ps1` | WSL is a Windows-side concern with no Linux-side peer | diff --git a/host-setup/windows/install-tools.ps1 b/host-setup/windows/install-tools.ps1 index 85c07021..2ddd9bba 100644 --- a/host-setup/windows/install-tools.ps1 +++ b/host-setup/windows/install-tools.ps1 @@ -360,7 +360,7 @@ function Invoke-WingetRemove { # Managed tools, in the order a report lists them. # Every one is a single winget package, which is what makes this a table where the Linux script needs four functions per tool. -# Probe names the executable that proves the tool is present when winget knows no package for it, and it is py for python because that is the name a correctly set up Windows host carries. +# Probe names the executable that proves the tool is present when winget knows no package for it, and it is py for python rather than python3 because py is registered by the interpreter's own installer while python3 is created by Repair-PythonName below, and a probe that answers only because this script wrote it proves nothing about the package. # Family names the id prefix winget's catalog shares across every channel and pinned major a tool ships under, and is empty everywhere but node: node alone ships as more than one id (Current, LTS, and one per pinned major back to 4), any of which is fine on a host as long as its version clears Available, and Resolve-ToolPackage is what finds which one that is. $TOOLS = @( @{ Name = 'git'; Package = 'Git.Git'; Probe = 'git'; Optional = @(); Family = '' } @@ -504,11 +504,40 @@ function Get-ToolStatus { function Add-ToolNote { param([Parameter(Mandatory)][hashtable]$Tool, [Parameter(Mandatory)][hashtable]$State) if ($Tool.Name -eq 'python') { - # Written unexpanded, because the expanded form names a real account and the prose gate rejects that. - note 'python' 'python3 resolves to the Microsoft Store alias stub under %LOCALAPPDATA%\Microsoft\WindowsApps, so py -3 is the name this contract uses here' - $resolved = Get-Command python -ErrorAction SilentlyContinue - if ($resolved -and $resolved.Source -notmatch 'Python\d') { - note 'python' "python resolves to $(Hide-Home $resolved.Source), which is not the interpreter winget installed" + # The note reads the alias's own target package rather than describing every alias as the placeholder, because a report that promises an action the apply then refuses is worse than no report at all. + # A Microsoft Store Python registers aliases under these same two names carrying the same tag, and Repair-PythonName deliberately leaves those alone, so the two have to be told apart here as well. + foreach ($name in $script:PYTHON_ALIAS_NAMES) { + $stub = Join-Path $script:PYTHON_ALIAS_DIR $name + if (-not (Test-AppExecutionAlias -Path $stub)) { continue } + $package = Get-AppExecutionAliasPackage -Path $stub + if (-not $package) { + note 'python' "$name in the app execution alias directory is an app execution alias whose target package cannot be read, so an apply of python leaves it alone rather than removing it on a guess" + } elseif ($package.StartsWith($script:PYTHON_ALIAS_PACKAGE, [StringComparison]::OrdinalIgnoreCase)) { + note 'python' "$name in the app execution alias directory is the Microsoft Store placeholder, which only offers to open the Store and answers ahead of the installed interpreter wherever that directory sits earlier on PATH. Any apply of python removes it, whether -Install, -Upgrade or -Reinstall" + } else { + note 'python' "$name in the app execution alias directory belongs to $package rather than the Microsoft Store placeholder, so it reaches an interpreter somebody chose and no apply of python removes it. Turn it off under Settings, Apps, Advanced app settings, App execution aliases if the managed interpreter should answer instead" + } + } + # The fleet contract uses python3 as the interpreter name on every platform, so what that name actually reaches here is the fact worth reporting. + # A foreign python3 earlier on PATH, from MSYS2, Cygwin or Scoop, is the state nothing else surfaces: it is a real interpreter and answers cleanly, so it is the wrong one silently rather than loudly, and PATH order alone decides it. + $line = Get-PythonLine -Version $State.Installed + $interpreter = if ($line) { Get-PythonLineInterpreter -Line $line } else { $null } + $shim = if ($interpreter) { Join-Path (Split-Path -Parent $interpreter) 'python3.exe' } else { $null } + $resolved = Get-Python3OnPath + if ($shim -and -not $resolved) { + note 'python' 'python3 answers nothing on PATH, and -Install python creates it beside the managed interpreter' + } elseif ($shim -and $resolved -ne $shim -and -not (Test-PythonVirtualEnv -Path $resolved)) { + note 'python' "python3 resolves to $(Hide-Home $resolved) rather than to the managed $line interpreter at $(Hide-Home $shim), and PATH order alone decides that" + } + # Compared against the managed interpreter where one could be resolved, and against the weaker shape test otherwise. + # The fallback matters because the registry lookup failing is exactly the state where a reader most needs to be told what python reaches, and a check that goes quiet there would be silent on the worst host rather than on the best one. + # Every read of the resolved command sits inside this guard rather than beside it, because Set-StrictMode turns a property read on a host where python answers nothing into a terminating error, which would crash the report on exactly the host that most needs one. + $python = @(Get-Command python -CommandType Application -ErrorAction SilentlyContinue) | Select-Object -First 1 + if ($python) { + $wrongPython = if ($interpreter) { $python.Source -ne $interpreter } else { $python.Source -notmatch 'Python\d' } + if ($wrongPython -and -not (Test-PythonVirtualEnv -Path $python.Source)) { + note 'python' "python resolves to $(Hide-Home $python.Source), which is not the interpreter winget installed" + } } } if ($State.Scope.Count -gt 1) { @@ -538,6 +567,248 @@ function Add-ToolNote { } } +# --- Python --- + +# The reparse tag every Windows app execution alias carries, IO_REPARSE_TAG_APPEXECLINK. +# Matched as the bare hex value rather than by the label fsutil prints beside it, because that label is localized and the value is not. +$APPEXECLINK_TAG = '0x8000001b' + +# The directory Windows keeps its app execution alias stubs in, which is on PATH by default. +# Read from the environment rather than written out, because the expanded form names a real account and the prose gate rejects that. +$PYTHON_ALIAS_DIR = Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps' + +# Both names rather than python3 alone: python.exe is stubbed the same way and lies identically the moment PATH order puts the alias directory ahead of a real interpreter. +$PYTHON_ALIAS_NAMES = @('python.exe', 'python3.exe') + +# The one package whose python aliases this script will remove, which is the App Installer placeholder that offers to open the Microsoft Store. +# The tag alone does not identify it: a Microsoft Store Python installs aliases under these same two names carrying this same tag, and those reach a real interpreter the operator chose, so removing one on the tag alone would delete a working setup rather than a placeholder. +$PYTHON_ALIAS_PACKAGE = 'Microsoft.DesktopAppInstaller_' + +# Whether a file is one of Windows' app execution alias stubs rather than a real executable someone put there. +# Four conditions together, because the deletion this gates cannot be undone and the alias directory is one a person may legitimately have put a program in: the file exists, it is empty, it is a reparse point, and its reparse tag is the app execution alias one. +# Reading the tag at all is what fsutil is here for, and no other reader answers with it: PowerShell surfaces the ReparsePoint attribute but leaves LinkType empty for this tag, so the attribute alone cannot tell an alias stub from a symlink or a junction, and deleting on the attribute alone would delete either. +function Test-AppExecutionAlias { + param([Parameter(Mandatory)][string]$Path) + $item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue + if (-not $item -or $item.PSIsContainer -or $item.Length -ne 0) { return $false } + if (-not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { return $false } + # Checked before it is called, the same way Stop-DockerDesktop checks for the docker CLI: an absent command is a PowerShell exception rather than a native exit code, so calling it blind would crash a report rather than answer one. + # Answering false where the tag cannot be read is the safe direction, since every caller only ever deletes on a true. + if (-not (Get-Command fsutil.exe -ErrorAction SilentlyContinue)) { return $false } + $text = (& fsutil.exe reparsepoint query $Path 2>&1 | Out-String) + if ($LASTEXITCODE -ne 0) { return $false } + return ($text -match [regex]::Escape($script:APPEXECLINK_TAG)) +} + +# The package family an app execution alias points at, or nothing where that cannot be read. +# Only fsutil exposes the reparse buffer at all, and it prints it as a hex dump, so the bytes are decoded from the hex columns rather than from the ASCII rendering beside them, which is lossy for every byte outside printable ASCII. +# The buffer is UTF-16 and holds several null separated strings, of which the family is the one carrying an underscore and no path separator, verified against the Windows Terminal, App Installer, Notepad and Get Help aliases on a fleet host. +function Get-AppExecutionAliasPackage { + param([Parameter(Mandatory)][string]$Path) + if (-not (Get-Command fsutil.exe -ErrorAction SilentlyContinue)) { return $null } + $text = (& fsutil.exe reparsepoint query $Path 2>&1 | Out-String) + if ($LASTEXITCODE -ne 0) { return $null } + $bytes = [System.Collections.Generic.List[byte]]::new() + foreach ($line in ($text -split "`r?`n")) { + if ($line -notmatch '^[0-9a-f]{4}:\s+(.*)$') { continue } + # Capped at the sixteen bytes a dump line carries, so the ASCII column beside them cannot contribute a pair that happens to read as hex. + $tokens = @(($Matches[1] -split '\s+') | Where-Object { $_ -match '^[0-9a-f]{2}$' } | Select-Object -First 16) + foreach ($token in $tokens) { $bytes.Add([Convert]::ToByte($token, 16)) } + } + if ($bytes.Count -eq 0) { return $null } + $decoded = [Text.Encoding]::Unicode.GetString($bytes.ToArray()) + $family = @($decoded -split "`0" | Where-Object { $_ -match '_' -and $_ -notmatch '[\\/:]' }) + if ($family.Count -gt 0) { return $family[0] } + return $null +} + +# The major.minor line a version names, which is the unit winget publishes Python under and the unit PEP 514 registers it under. +# Empty where the version names none, which a caller checks rather than proceeding on a guess. +function Get-PythonLine { + param([string]$Version) + if ($Version -match '^(\d+)\.(\d+)') { return "$($Matches[1]).$($Matches[2])" } + return '' +} + +# The registry roots PEP 514 interpreters register under, in the order a lookup should prefer them. +# The 32 bit view is listed because a 32 bit interpreter on a 64 bit host registers there and nowhere else, and an ARM64 host reaches an emulated x64 install the same way. +$PYTHON_REGISTRY_ROOTS = @( + 'HKCU:\SOFTWARE\Python\PythonCore' + 'HKLM:\SOFTWARE\Python\PythonCore' + 'HKCU:\SOFTWARE\WOW6432Node\Python\PythonCore' + 'HKLM:\SOFTWARE\WOW6432Node\Python\PythonCore' +) + +# Every registered key for one line, as root and key name, bare key first. +# PEP 514 lets a line qualify its tag by architecture, so 3.13 is joined by 3.13-32 on a 32 bit install and 3.13-arm64 on an ARM64 one, and a lookup that matches only the bare tag finds nothing on either host while the interpreter is plainly installed. +# The bare tag sorts first so a host carrying both is answered with its native build rather than whichever key enumerated first. +# The bare tags and the qualified ones are collected into separate lists and joined at the end, rather than one list a bare tag is pushed onto the front of. +# Pushing onto the front looks like it expresses the same preference and does not: each root's bare tag lands ahead of the one before it, so the last root searched is answered first and the root order above is inverted for exactly the hosts that carry a line in both. +function Get-PythonLineTag { + param([Parameter(Mandatory)][string]$Line) + $bare = @() + $qualified = @() + foreach ($root in $script:PYTHON_REGISTRY_ROOTS) { + foreach ($key in (Get-ChildItem -LiteralPath $root -ErrorAction SilentlyContinue)) { + if ($key.PSChildName -eq $Line) { $bare += @{ Root = $root; Name = $key.PSChildName } } + elseif ($key.PSChildName -like "$Line-*") { $qualified += @{ Root = $root; Name = $key.PSChildName } } + } + } + return , ($bare + $qualified) +} + +# The interpreter one Python line installed, read from that line's own PEP 514 registry key. +# That key is what the python.org installer writes and what the py launcher itself reads, so it answers for the line asked about rather than for whatever PATH resolves, which is the whole difference this function exists for, and it answers on a host whose PATH has not been refreshed since the install either. +# HKCU then HKLM, matching the two scopes winget installs into, and the second is not a fallback for a missing line: a line installed machine wide is registered in that root and in no other. +function Get-PythonLineInterpreter { + param([Parameter(Mandatory)][string]$Line) + foreach ($tag in (Get-PythonLineTag -Line $Line)) { + $properties = Get-ItemProperty -LiteralPath "$($tag.Root)\$($tag.Name)\InstallPath" -ErrorAction SilentlyContinue + if (-not $properties) { continue } + # ExecutablePath is preferred over the key's default value because PEP 514 lets a line name an executable that is not the default value's own python.exe, and reading the directory would then name a file that line does not run. + $default = [string]$properties.'(default)' + $candidates = @($properties.ExecutablePath) + if ($default) { $candidates += (Join-Path $default 'python.exe') } + foreach ($candidate in $candidates) { + if ($candidate -and (Test-Path -LiteralPath $candidate -PathType Leaf)) { return $candidate } + } + } + return $null +} + +# Every Python line PEP 514 says is installed here, as line to interpreter. +# That is more than the one line this script manages, because each winget id installs its own copy and uninstalling one does not touch another, so lines accumulate on a host that has ever moved between them. +function Get-PythonLineMap { + $map = [ordered]@{} + foreach ($root in $script:PYTHON_REGISTRY_ROOTS) { + foreach ($key in (Get-ChildItem -LiteralPath $root -ErrorAction SilentlyContinue)) { + # Keyed on the bare line rather than the architecture qualified tag, so a host carrying 3.13 and 3.13-32 reports one line rather than two that mean the same thing to a caller comparing against the managed one. + $line = ($key.PSChildName -split '-')[0] + if ($map.Contains($line)) { continue } + $interpreter = Get-PythonLineInterpreter -Line $line + if ($interpreter) { $map[$line] = $interpreter } + } + } + return $map +} + +# Where python3 answers from right now, or nothing where it does not answer at all. +# Filtered to an application because Get-Command alone also answers with a function or an alias defined in this session, and neither of those is what a command run from another shell reaches. +function Get-Python3OnPath { + $found = @(Get-Command 'python3' -CommandType Application -ErrorAction SilentlyContinue) | Select-Object -First 1 + if ($found) { return $found.Source } + return $null +} + +# Whether an interpreter is a virtual environment's rather than a host one. +# A report distinguishes the two because an activated environment legitimately puts its own interpreter first, which is right for running project code and is not drift worth naming, while every other interpreter answering ahead of the managed one is. +# The marker is the environment's own pyvenv.cfg, which sits one directory above the Scripts directory the interpreter is in. +function Test-PythonVirtualEnv { + param([string]$Path) + if (-not $Path) { return $false } + $root = Split-Path -Parent (Split-Path -Parent $Path) + if (-not $root) { return $false } + return (Test-Path -LiteralPath (Join-Path $root 'pyvenv.cfg') -PathType Leaf) +} + +# On Windows the name python3 lies twice over, and this is the one place both lies are fixed. +# Windows ships app execution alias stubs called python.exe and python3.exe that only ever offer to open the Microsoft Store. +# They fail loudly rather than quietly, writing to stderr and exiting 9009 as measured on a fleet host, so a chained command does stop on one, and the harm is not a false pass. +# What they do is occupy the name: the alias directory is on PATH by default, so wherever it sits ahead of the install directory the stub answers and the installed interpreter cannot be reached under that name at all. +# Removing the stub file is the only mechanism here, and it frees the name immediately, which is verified rather than assumed. +# It is not the same act as the Settings toggle, though, and the difference is measured: the toggle keeps reading On afterwards, since whatever store it reads is not this file and no documented interface writes that store. +# So the alias can return when App Installer is next serviced, this removal runs again on the next apply rather than assuming a host stays fixed, and turning the toggle off in Settings is what stops it coming back at all. +# The package's own copy under the App Installer subdirectory is deliberately untouched, since winget.exe and the package manager server sit beside it and removing one would break winget itself. +# And the interpreter winget installs registers python.exe but no python3.exe of its own, so removing the stub alone would leave the name simply absent on a host that does have Python. +# Both halves therefore run together, the stub goes and a real python3.exe is put beside the interpreter this script manages, which is what makes python3 the one interpreter name the fleet uses on every platform rather than a name that means something different here. +# Nothing below names a version: the line comes from whatever winget reports installed for the managed id, so this keeps working unchanged when that id moves to a later line. +function Repair-PythonName { + param([string]$Installed) + + # The replacement is resolved before anything is removed, and a failure to resolve one ends the run here with both aliases still in place. + # The other order frees the name and then discovers it has nothing to put there, which is worse than either half alone: an unmanaged or unreadable python leaves a host with the Store alias deleted, no python3 created, and not even the Store offer it started with. + $line = Get-PythonLine -Version $Installed + if (-not $line) { + note 'python' 'winget reports no version installed for the managed package, so there is no interpreter here for python3 to sit beside, and the app execution aliases are left alone rather than removed with nothing to replace them' + return + } + $interpreter = Get-PythonLineInterpreter -Line $line + if (-not $interpreter) { + note 'python' "winget reports $line installed and no interpreter is registered under that line, so python3 was not created and the app execution aliases are left alone. Run this again once the installer has finished writing its registry entries" + return + } + + foreach ($name in $script:PYTHON_ALIAS_NAMES) { + $stub = Join-Path $script:PYTHON_ALIAS_DIR $name + if (-not (Test-AppExecutionAlias -Path $stub)) { continue } + # Which package the alias points at decides whether it goes, because the tag alone cannot tell a placeholder from a working setup. + # An unreadable family is left alone on the same rule, since not knowing what an alias reaches is a reason to keep it rather than a reason to delete it. + $package = Get-AppExecutionAliasPackage -Path $stub + if (-not $package) { + warn "$name is an app execution alias whose target package could not be read, so it is left alone rather than removed on a guess" + continue + } + if (-not $package.StartsWith($script:PYTHON_ALIAS_PACKAGE, [StringComparison]::OrdinalIgnoreCase)) { + warn "$name is an app execution alias belonging to $package rather than the Microsoft Store placeholder, so it reaches an interpreter somebody chose and is left alone. Turn it off under Settings, Apps, Advanced app settings, App execution aliases if the managed interpreter should answer instead" + continue + } + info "Removing the $name app execution alias stub from $(Hide-Home $script:PYTHON_ALIAS_DIR)" + if ($script:DRY_RUN) { + Write-Host " [dry run] Remove-Item $(Hide-Home $stub)" + continue + } + Remove-Item -LiteralPath $stub -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $stub) { + warn "could not remove the $name app execution alias stub, so it still answers ahead of a real interpreter. Turn it off by hand under Settings, Apps, Advanced app settings, App execution aliases" + } else { + $script:CHANGED += "python removed the $name app execution alias stub" + } + } + + # Beside the interpreter rather than anywhere else on PATH, and a copy rather than a launcher script. + # The containing directory is what CPython resolves its own prefix from, so a copy placed there runs as that interpreter and a copy placed anywhere else does not run at all. + # A .cmd or .bat shim would be worse than useless: Git Bash appends .exe and nothing else when it resolves a bare name, so a shim in either of those forms is invisible to exactly the shell the reported failure came from. + $shim = Join-Path (Split-Path -Parent $interpreter) 'python3.exe' + $source = Get-Item -LiteralPath $interpreter -ErrorAction SilentlyContinue + $existing = Get-Item -LiteralPath $shim -ErrorAction SilentlyContinue + if ($existing -and $source -and $existing.Length -eq $source.Length -and $existing.LastWriteTime -eq $source.LastWriteTime) { + info "python3 already sits beside the $line interpreter at $(Hide-Home $shim)" + } elseif ($script:DRY_RUN) { + info "Creating python3 beside the $line interpreter" + Write-Host " [dry run] Copy-Item $(Hide-Home $interpreter) $(Hide-Home $shim)" + } else { + info "Creating python3 beside the $line interpreter at $(Hide-Home $shim)" + # Two different failures land here and the remedy differs, so the message names both rather than guessing between them. + # A machine wide install sits under a directory this unelevated run cannot write, and the rest of this script deliberately stays unelevated. + # And Windows will not replace an executable that is open, which is what an editor or a language server still holding the old shim looks like, so the process is named where one is running, the same way a failed winget install names it. + try { + Copy-Item -LiteralPath $interpreter -Destination $shim -Force -ErrorAction Stop + $script:CHANGED += "python created python3 beside the $line interpreter" + } catch { + $holding = @(Get-Process -Name 'python3' -ErrorAction SilentlyContinue) + $remedy = if ($holding.Count -gt 0) { + "python3 is running as process $($holding.Id -join ', '), and Windows cannot replace a running executable, so close whatever is running it and run this again" + } else { + 'copy python.exe to python3.exe there by hand from a prompt that can write that directory, or reinstall python user wide' + } + # Warned and failed rather than noted, because this is the half of the contract that did not hold: python is installed and python3 still does not reach it. + # A note would be the wrong instrument twice over, since it reads as an aside and, on an apply, it is printed under a heading a reader scanning for failures does not look at. + warn "python could not write python3 beside the $line interpreter at $(Hide-Home $shim): $($_.Exception.Message). $remedy" + $script:FAILED += 'python' + return + } + } + + # A line this script does not manage can carry a python3 of its own and win on PATH, so it is reported and left alone: this run did not necessarily create it, and removing another line's interpreter is not this script's to do. + foreach ($entry in (Get-PythonLineMap).GetEnumerator()) { + if ($entry.Key -eq $line) { continue } + $stale = Join-Path (Split-Path -Parent $entry.Value) 'python3.exe' + if (Test-Path -LiteralPath $stale -PathType Leaf) { + note 'python' "$($entry.Key) carries a python3 of its own at $(Hide-Home $stale), and PATH order alone decides which one answers; remove it by hand if that line is no longer wanted" + } + } +} + # --- WSL --- # Docker Desktop's own documented floor for the WSL platform, per docs.docker.com/desktop/features/wsl. @@ -893,6 +1164,13 @@ function Invoke-Apply { foreach ($tool in $script:SELECTED) { step $tool Invoke-ToolApply -ToolName $tool + # Owed after the package itself is settled, on every path Invoke-ToolApply returns by rather than only the one where winget did work, which is where this deviates from the docker branch inside that function. + # Docker Desktop only needs stopping when the package is about to move, so its window belongs past the statuses that leave a tool alone, while an app execution alias stub returns on a host whose python has been current for months and a reinstall removes the interpreter this puts python3 beside, so this belongs after all of them instead. + # A failed tool is the one case this does skip, and it is the case that matters most: a reinstall whose install half failed has already removed the interpreter, so removing the Store alias on top of it would leave the host with no python, no python3, and no way to get one. + # The version is re-read here rather than taken from inside that function, so the line it acts on is the one the host carries now. + if ($tool -eq 'python' -and $script:FAILED -notcontains $tool) { + Repair-PythonName -Installed (Resolve-InstalledVersion -Version (Get-WingetInstalled -Id (Get-Tool $tool).Package)) + } } log '' @@ -903,6 +1181,14 @@ function Invoke-Apply { log 'Nothing changed' } + # Printed here as well as under a report, because an apply is where most notes are actually raised and until now none of them reached the console. + # Every note this script writes during an apply was collected into a list only Show-Report rendered, so a declined docker maintenance cycle and every python finding were both discarded by the one mode that had just performed the work they describe. + if ($script:NOTES.Count -gt 0) { + log '' + log 'Notes:' + foreach ($entry in $script:NOTES) { info $entry } + } + if ($script:FAILED.Count -gt 0) { warn "Failed: $($script:FAILED -join ' ')" return 1 diff --git a/scripts/tests/test_bootstrap.py b/scripts/tests/test_bootstrap.py index 3e0d4b53..1a563e05 100755 --- a/scripts/tests/test_bootstrap.py +++ b/scripts/tests/test_bootstrap.py @@ -384,6 +384,45 @@ def test_windows_installer_requires_string_package_metadata(self) -> None: self.assertIn("$metadata.package -isnot [string]", text) self.assertIn("$metadataKeys.Count -ne 2", text) + def test_windows_installer_supplies_the_python3_name(self) -> None: + """Windows has no `python3` of its own, so the installer both clears the lie and supplies the name. + + Two halves, and neither is sufficient alone. Windows ships app-execution alias stubs that + answer to `python` and `python3` and only offer to open the Microsoft Store. They fail + loudly, on stderr with exit 9009, so the harm is not a false pass but an occupied name: the + alias directory is on `PATH` by default, so the stub answers wherever it sits ahead of the + install directory. Removing them alone would leave `python3` simply absent on a host that + does have Python, so the installer also puts a real one beside the interpreter it manages. + The reparse-tag guard is what keeps the removal from deleting a real executable someone put + in that directory. + """ + text = (WINDOWS / "install-tools.ps1").read_text(encoding="utf-8") + self.assertIn("function Repair-PythonName", text) + self.assertIn("function Test-AppExecutionAlias", text) + self.assertIn("0x8000001b", text) + self.assertIn("$PYTHON_ALIAS_NAMES = @('python.exe', 'python3.exe')", text) + self.assertIn("Repair-PythonName -Installed", text) + + def test_windows_python3_shim_names_no_version(self) -> None: + """The shim follows whatever line the managed package installs, so its code hardcodes none. + + The package ID in `$TOOLS` is the one place a Python version is written down. A literal + anywhere in the repair path would silently keep targeting the old line the day that ID moves, + which is the failure mode this asserts against rather than merely documents. + + Comments are stripped before the match, because the invariant is about what the code + resolves and not about what the prose may use as an example. The architecture-qualified tag + rule is far clearer written as `3.13` beside `3.13-arm64` than described in the abstract, and + a version named there cannot make a lookup target the wrong line. + """ + text = (WINDOWS / "install-tools.ps1").read_text(encoding="utf-8") + section = text.split("# --- Python ---", 1) + self.assertEqual(len(section), 2, "install-tools.ps1 carries no Python section") + body = section[1].split("# --- WSL ---", 1)[0] + code = "\n".join(line for line in body.splitlines() if not line.lstrip().startswith("#")) + self.assertNotRegex(code, r"Python3?\d\d|\d+\.\d+") + self.assertIn("Get-PythonLine -Version $Installed", code) + def test_every_declared_floor_carries_a_total_remedy_mapping(self) -> None: """Each floored tool names a runnable remedy on every platform, or carries a recorded exception. diff --git a/spec/host-tools.json b/spec/host-tools.json index f79d2fd6..dac09141 100644 --- a/spec/host-tools.json +++ b/spec/host-tools.json @@ -84,14 +84,14 @@ { "name": "python3", "required": true, - "probes": [["python3", "--version"], ["py", "-3", "--version"]], + "probes": [["py", "-3", "--version"], ["python3", "--version"]], "pattern": "Python (\\d+(?:\\.\\d+)*)", "minimum": "3.13", - "why": "Every script here is standard library only, so a bare interpreter is enough and no package floor exists. The floor is the toolchain target rather than a measured breakage one version below it, which is the one entry here that reads that way and says so rather than implying a defect nobody found. pyproject.toml sets ruff target-version to py313 and mypy python_version to 3.13, so what those tools report describes 3.13 and describes no other interpreter, and a run below the floor is unverified rather than known broken. Both tools run in CI through uvx at their latest releases, beside the doc linters, the registry and spec validation, the script self-tests under coverage, and the repo and prose gates. That run judges the code rather than any host, so this floor is still a configuration choice rather than an enforced result, and a host failing it has no CI failure to point at. Two hard requirements are measured, both sit lower, and they fail differently. str.removeprefix and str.removesuffix need 3.9, and each is called where the tree actually calls it: removeprefix in scripts/prose_lint.py and spec/audit.py, removesuffix in spec/audit.py alone. An older interpreter starts, runs, and raises AttributeError when it reaches one. datetime.UTC needs 3.11 and arrives through a module-level from datetime import UTC in spec/audit.py, scripts/pr_review.py and its tests, so an older interpreter raises ImportError before any of those modules run at all. Which mode a host sees is decided by the script it runs rather than by the interpreter alone: spec/audit.py carries both and fails at import, scripts/pr_review.py carries only the import and fails the same way, and scripts/prose_lint.py carries only the call and therefore starts, runs, and fails partway through. The name rather than the version is what differs per platform, which the second probe covers. Two scripts outside this gate probe a lower number on purpose rather than by drift: repo-config/configure.sh accepts 3.7+ because that is the oldest interpreter resolve_description.py happens to run on (PEP 563), not a second floor for this repo, and scripts/skills_install.sh / host-setup/*/install-skills.* accept 3.7+ because they must run on whatever a host already has before this floor's own toolchain exists to install one. Neither is evidence that spec/ or scripts/ code should hedge for anything older than 3.13.", + "why": "Every script here is standard library only, so a bare interpreter is enough and no package floor exists. The floor is the toolchain target rather than a measured breakage one version below it, which is the one entry here that reads that way and says so rather than implying a defect nobody found. pyproject.toml sets ruff target-version to py313 and mypy python_version to 3.13, so what those tools report describes 3.13 and describes no other interpreter, and a run below the floor is unverified rather than known broken. It is a floor and not a ceiling, which matters because the platform rather than this repo decides how high a host can go: a Linux distribution ships the line it ships, so a host at the floor and a host above it both pass, and moving the line the Windows installer pins is a deliberate change made together with the toolchain settings that have to move alongside it. Both tools run in CI through uvx at their latest releases, beside the doc linters, the registry and spec validation, the script self-tests under coverage, and the repo and prose gates. That run judges the code rather than any host, so this floor is still a configuration choice rather than an enforced result, and a host failing it has no CI failure to point at. Two hard requirements are measured, both sit lower, and they fail differently. str.removeprefix and str.removesuffix need 3.9, and each is called where the tree actually calls it: removeprefix in scripts/prose_lint.py and spec/audit.py, removesuffix in spec/audit.py alone. An older interpreter starts, runs, and raises AttributeError when it reaches one. datetime.UTC needs 3.11 and arrives through a module-level from datetime import UTC in spec/audit.py, scripts/pr_review.py and its tests, so an older interpreter raises ImportError before any of those modules run at all. Which mode a host sees is decided by the script it runs rather than by the interpreter alone: spec/audit.py carries both and fails at import, scripts/pr_review.py carries only the import and fails the same way, and scripts/prose_lint.py carries only the call and therefore starts, runs, and fails partway through. The probes are ordered py first for a reason about which interpreter answers rather than about which name exists. python3 is the fleet's name on every platform, Windows included, since host-setup/windows/install-tools.ps1 supplies it there. But this gate measures the host's interpreter, and a bare python3 is reached through PATH, where on Windows an activated virtual environment or a foreign interpreter from MSYS2, Cygwin or Scoop answers ahead of the managed one and would be graded in its place. py is the launcher, reaches a registered system interpreter whatever is active, and exists on no other platform, so it answers first here and not at all there, which leaves the python3 probe to answer for Linux and macOS. Two scripts outside this gate probe a lower number on purpose rather than by drift: repo-config/configure.sh accepts 3.7+ because that is the oldest interpreter resolve_description.py happens to run on (PEP 563), not a second floor for this repo, and scripts/skills_install.sh / host-setup/*/install-skills.* accept 3.7+ because they must run on whatever a host already has before this floor's own toolchain exists to install one. Neither is evidence that spec/ or scripts/ code should hedge for anything older than 3.13.", "source": { "linux": "Whatever the platform provides at or above the floor, since the scripts need an interpreter and no packages, so no distribution or build is pinned here.", "macos": "Whatever the platform provides at or above the floor, on the same reasoning as Linux.", - "windows": "The python.org installer, which registers py, python and python3.13 but not python3, so the gate reaches it through the py -3 probe rather than the python3 name that resolves to the Microsoft Store alias stub." + "windows": "The python.org installer, reached through winget, which registers py and python but no python3 of its own. host-setup/windows/install-tools.ps1 supplies that name, removing the Windows app execution alias stubs that answer to it and putting a real python3 beside the interpreter it manages, so python3 means the same thing here as it does on the other two platforms." }, "remedy": { "linux": "host-setup/linux/install-tools.sh --upgrade python",