From 5554b205d146bdc6cf70fa5366ed22294bbaef07 Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Mon, 5 Oct 2026 21:18:44 +0500 Subject: [PATCH 1/2] fix(powershell): probe each Python 3 candidate before selecting it `Get-Python3Command` returned `python3` on mere `Get-Command` presence, with no execution probe -- unlike its own `python` and `py -3` branches. On Windows `python3` almost always resolves to the Microsoft Store App Execution Alias stub, which `Get-Command` finds but which fails at runtime, so callers invoked a dead interpreter instead of falling through to a working one. The Bash twin (`_python3_command`) already probes all three candidates. The existing `python` / `py -3` probes had a second problem with the same root: `& python --version 2>&1` under the `$ErrorActionPreference = 'Stop'` that every caller sets raises a terminating NativeCommandError when the probed exe writes to stderr, rather than simply failing the match. `Test-Python3Command` now probes each fallback candidate without throwing, and requires a zero exit status as well as a "Python 3" banner -- the Bash twin gates purely on exit status, so a wrapper that echoes a version and then fails must not be selected. The `SPECKIT_PYTHON_EXECUTABLE` override block that main added above these branches is left unchanged. Tests shim fake interpreters onto PATH in both forms -- an executable script always, plus a `.cmd` on Windows -- because `HAS_POWERSHELL` is also true for `pwsh` on Linux and macOS. The scripts use an absolute `#!/bin/sh`: the driver replaces PATH with the shim directory alone, so `#!/usr/bin/env sh` cannot find `sh` and every shim exits 127, which is why these tests failed on macOS CI while passing on Windows. The driver also strips the SPECKIT_PYTHON override variables, which would otherwise bypass the shims entirely. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/powershell/common.ps1 | 53 +++++- tests/test_resolve_template_python_parity.py | 166 +++++++++++++++++++ 2 files changed, 210 insertions(+), 9 deletions(-) diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index c90308ef9c..cf186ecdfd 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -324,6 +324,44 @@ function Format-SpecKitCommand { return "/speckit$separator$name" } +# Probe a candidate interpreter by running it, returning $true only when it +# really is a Python 3. Selection must be by execution success, not by mere +# availability: on Windows 'python3' (and often 'python') resolves to the +# Microsoft Store App Execution Alias stub, which Get-Command finds but which +# fails at runtime -- the same hazard scripts/bash/common.sh documents and +# defends against in _python3_command. +# +# The probe is deliberately non-throwing. Callers set +# $ErrorActionPreference = 'Stop', and in Windows PowerShell redirecting a +# native command's stderr into the success stream wraps each line in an +# ErrorRecord, so '& python --version 2>&1' raised a terminating +# NativeCommandError against the stub rather than simply failing the match. +function Test-Python3Command { + param( + [Parameter(Mandatory = $true)][string]$Executable, + [string[]]$Arguments = @() + ) + + $previousPreference = $ErrorActionPreference + $ErrorActionPreference = 'SilentlyContinue' + try { + $versionOutput = & $Executable @Arguments --version 2>&1 + # Capture the exit status IMMEDIATELY, before any other statement can + # disturb it. Selection is by execution *success*, so a non-zero status + # disqualifies the candidate no matter what it printed: a broken wrapper + # that echoes its requested version and then exits non-zero would + # otherwise be selected here and fail at the point of use. The bash twin + # (_python3_command in scripts/bash/common.sh) gates purely on exit + # status, so requiring it here keeps the two in step. + $exitCode = $LASTEXITCODE + return (($exitCode -eq 0) -and (($versionOutput -join ' ') -match 'Python 3')) + } catch { + return $false + } finally { + $ErrorActionPreference = $previousPreference + } +} + # Find a usable Python 3 executable (python3, python, or py -3). # Returns the command/arguments as an array, or $null if none found. function Get-Python3Command { @@ -337,15 +375,12 @@ function Get-Python3Command { if ($LASTEXITCODE -eq 0) { return @($override) } } } - if (Get-Command python3 -ErrorAction SilentlyContinue) { return @('python3') } - if (Get-Command python -ErrorAction SilentlyContinue) { - $ver = & python --version 2>&1 - if ($ver -match 'Python 3') { return @('python') } - } - if (Get-Command py -ErrorAction SilentlyContinue) { - $ver = & py -3 --version 2>&1 - if ($ver -match 'Python 3') { return @('py', '-3') } - } + if ((Get-Command python3 -ErrorAction SilentlyContinue) -and + (Test-Python3Command -Executable 'python3')) { return @('python3') } + if ((Get-Command python -ErrorAction SilentlyContinue) -and + (Test-Python3Command -Executable 'python')) { return @('python') } + if ((Get-Command py -ErrorAction SilentlyContinue) -and + (Test-Python3Command -Executable 'py' -Arguments @('-3'))) { return @('py', '-3') } return $null } diff --git a/tests/test_resolve_template_python_parity.py b/tests/test_resolve_template_python_parity.py index 7e5f3a2088..e2d7970b59 100644 --- a/tests/test_resolve_template_python_parity.py +++ b/tests/test_resolve_template_python_parity.py @@ -4,6 +4,7 @@ import json import os +import re import subprocess import sys import time @@ -15,6 +16,8 @@ from tests.conftest import requires_bash from tests.parity_helpers import ( HAS_POWERSHELL, + PROJECT_ROOT, + POWERSHELL_EXE, bash_cmd, clean_env, install_composition_stack, @@ -1508,3 +1511,166 @@ def test_all_variants_fail_for_malformed_preset_manifest( assert all(result.returncode != 0 for result in results) assert all(result.stdout == "" for result in results) + + +# -- Get-Python3Command interpreter selection ----------------------------- + +# Fake interpreters, in both shim forms. ``HAS_POWERSHELL`` is true whenever +# ``pwsh`` is on PATH — including on Linux and macOS — where a ``.cmd`` file is +# not executable and would never resolve from PATH. So each shim is written as +# an executable shebang script (the form PowerShell resolves on POSIX) plus a +# ``.cmd`` on Windows, mirroring ``tests/extensions/test_extension_agent_context.py``. +# +# The shebang is the absolute ``#!/bin/sh``, NOT ``#!/usr/bin/env sh``: the +# probe driver replaces PATH with the shim directory alone, so ``env`` cannot +# find ``sh`` and every shim exits 127 -- which made all three selection tests +# fail on macOS CI while passing on Windows, where the ``.cmd`` path is used. +_STORE_ALIAS_STUB = { + "cmd": ( + "@echo off\r\n" + "echo Python was not found; run without arguments to install from the " + "Microsoft Store. 1>&2\r\n" + "exit /b 9009\r\n" + ), + "sh": ( + "#!/bin/sh\n" + "echo 'Python was not found; run without arguments to install from the " + "Microsoft Store.' >&2\n" + "exit 9009\n" + ), +} +_WORKING_PYTHON3 = { + "cmd": "@echo off\r\necho Python 3.12.0\r\nexit /b 0\r\n", + "sh": "#!/bin/sh\necho 'Python 3.12.0'\nexit 0\n", +} +_WORKING_PY_LAUNCHER = { + "cmd": ( + "@echo off\r\n" + 'if "%1"=="-3" (echo Python 3.12.0 & exit /b 0)\r\n' + "exit /b 1\r\n" + ), + "sh": ( + "#!/bin/sh\n" + "if [ \"$1\" = \"-3\" ]; then echo 'Python 3.12.0'; exit 0; fi\n" + "exit 1\n" + ), +} +# Prints a perfectly valid version banner and then fails. Only an exit-status +# check can reject it. +_LYING_WRAPPER = { + "cmd": "@echo off\r\necho Python 3.12.0\r\nexit /b 1\r\n", + "sh": "#!/bin/sh\necho 'Python 3.12.0'\nexit 1\n", +} + + +def _run_get_python3_command(tmp_path: Path, shims: dict[str, dict[str, str]]) -> str: + """Dot-source common.ps1 with a PATH of *shims* and report the selection. + + Returns the selected command joined by spaces, ``""`` when nothing usable + was found, or ``THREW: `` if the call raised. Callers run with + ``$ErrorActionPreference = 'Stop'``, so this mirrors real usage. + """ + shim_dir = tmp_path / "shims" + shim_dir.mkdir() + for name, spec in shims.items(): + posix_shim = shim_dir / name + posix_shim.write_text(spec["sh"], encoding="ascii", newline="\n") + posix_shim.chmod(0o755) + if os.name == "nt": + (shim_dir / f"{name}.cmd").write_text(spec["cmd"], encoding="ascii") + + common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + driver = tmp_path / "probe.ps1" + driver.write_text( + "$ErrorActionPreference = 'Stop'\r\n" + f"$env:PATH = '{shim_dir}'\r\n" + f". '{common_ps}'\r\n" + "try { $r = Get-Python3Command; 'RESULT=[' + ($r -join ' ') + ']' }\r\n" + "catch { 'RESULT=[THREW: ' + $_.CategoryInfo.Reason + ']' }\r\n", + encoding="ascii", + ) + + # The SPECKIT_PYTHON_EXECUTABLE / SPECKIT_PYTHON override is consulted + # before any candidate, and clean_env() only strips SPECIFY_* keys -- so a + # runner that sets either would bypass the shims and test the override. + env = clean_env() + env.pop("SPECKIT_PYTHON_EXECUTABLE", None) + env.pop("SPECKIT_PYTHON", None) + + result = subprocess.run( + [POWERSHELL_EXE, "-NoProfile", "-File", str(driver)], + capture_output=True, + text=True, + check=False, + env=env, + ) + match = re.search(r"RESULT=\[(.*)\]", result.stdout) + assert match, f"stdout={result.stdout!r} stderr={result.stderr!r}" + return match.group(1) + + +@pytest.mark.skipif(not HAS_POWERSHELL, reason="PowerShell not available") +def test_get_python3_command_skips_unusable_python3(tmp_path: Path) -> None: + """A 'python3' that Get-Command finds but that fails to run must be skipped. + + On Windows 'python3' commonly resolves to the Microsoft Store App Execution + Alias stub. The first branch used to return @('python3') on mere + Get-Command presence, with no execution probe -- unlike its own second and + third branches -- so callers invoked the dead stub instead of falling + through to a working interpreter. + """ + selected = _run_get_python3_command( + tmp_path, + {"python3": _STORE_ALIAS_STUB, "python": _WORKING_PYTHON3}, + ) + assert selected == "python" + + +@pytest.mark.skipif(not HAS_POWERSHELL, reason="PowerShell not available") +def test_get_python3_command_probe_does_not_throw_under_stop( + tmp_path: Path, +) -> None: + """Probing must fail the match, not raise, when a candidate writes stderr. + + Callers set $ErrorActionPreference = 'Stop', and redirecting a native + command's stderr into the success stream wraps each line in an ErrorRecord, + so the probe raised a terminating NativeCommandError instead of moving on. + """ + selected = _run_get_python3_command(tmp_path, {"python": _STORE_ALIAS_STUB}) + assert selected == "" + + +@pytest.mark.skipif(not HAS_POWERSHELL, reason="PowerShell not available") +def test_get_python3_command_falls_through_to_py_launcher( + tmp_path: Path, +) -> None: + """A working 'py -3' must still be reached past two unusable candidates.""" + selected = _run_get_python3_command( + tmp_path, + { + "python3": _STORE_ALIAS_STUB, + "python": _STORE_ALIAS_STUB, + "py": _WORKING_PY_LAUNCHER, + }, + ) + assert selected == "py -3" + + +@pytest.mark.skipif(not HAS_POWERSHELL, reason="PowerShell not available") +def test_get_python3_command_rejects_a_candidate_that_exits_nonzero( + tmp_path: Path, +) -> None: + """A candidate that prints a valid version but fails must be rejected. + + Selection is by execution *success*. Matching only on the version banner + accepted any process whose output happened to contain "Python 3", so a + broken wrapper that echoes its requested version and then exits non-zero + was selected here and failed at the point of use instead. The Bash twin + (``_python3_command``) gates purely on exit status, so matching output + alone also put the two implementations out of step. + """ + selected = _run_get_python3_command( + tmp_path, + {"python3": _LYING_WRAPPER, "python": _WORKING_PYTHON3}, + ) + assert selected == "python" From 831f66774476906c6c374d42c8c4aec128b011e2 Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Mon, 5 Oct 2026 21:52:21 +0500 Subject: [PATCH 2/2] test(powershell): quote and encode the generated probe driver safely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The probe driver embeds `shim_dir` and the `common.ps1` path into single-quoted PowerShell literals and was written as ASCII, so: - a non-ASCII path (username, temp dir or checkout containing e.g. `é`) raised UnicodeEncodeError before PowerShell started, and - a path containing `'` (e.g. a `C:\Users\O'Brien` profile) terminated the literal early and failed with a PowerShell parse error. Double `'` the way the sibling parity tests already do, and write the driver as UTF-8 with a BOM so Windows PowerShell 5.1 does not read it in the ANSI code page. Reproduced both with `--basetemp` under such paths: 4 failed before, 4 passed after (also with both characters in one path). Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_resolve_template_python_parity.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/test_resolve_template_python_parity.py b/tests/test_resolve_template_python_parity.py index e2d7970b59..e96ab4e30b 100644 --- a/tests/test_resolve_template_python_parity.py +++ b/tests/test_resolve_template_python_parity.py @@ -1579,15 +1579,23 @@ def _run_get_python3_command(tmp_path: Path, shims: dict[str, dict[str, str]]) - if os.name == "nt": (shim_dir / f"{name}.cmd").write_text(spec["cmd"], encoding="ascii") - common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + # Both paths land inside single-quoted PowerShell literals, where a ``'`` + # (e.g. a ``C:\Users\O'Brien`` profile) must be doubled to ``''``. + shim_dir_ps = str(shim_dir).replace("'", "''") + common_ps = str(PROJECT_ROOT / "scripts" / "powershell" / "common.ps1").replace( + "'", "''" + ) driver = tmp_path / "probe.ps1" driver.write_text( "$ErrorActionPreference = 'Stop'\r\n" - f"$env:PATH = '{shim_dir}'\r\n" + f"$env:PATH = '{shim_dir_ps}'\r\n" f". '{common_ps}'\r\n" "try { $r = Get-Python3Command; 'RESULT=[' + ($r -join ' ') + ']' }\r\n" "catch { 'RESULT=[THREW: ' + $_.CategoryInfo.Reason + ']' }\r\n", - encoding="ascii", + # UTF-8 *with* a BOM: the embedded paths can be non-ASCII (a username, + # temp dir or checkout path containing e.g. ``é``), and Windows + # PowerShell 5.1 reads a BOM-less script in the ANSI code page. + encoding="utf-8-sig", ) # The SPECKIT_PYTHON_EXECUTABLE / SPECKIT_PYTHON override is consulted