From a87bb4ce5a50a8b6e6be63d84ccb9a393f36d251 Mon Sep 17 00:00:00 2001 From: Myles Anderson <135627999+myles332@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:14:10 -0700 Subject: [PATCH] OR-346: Route Windows local runs' python around the Store alias stub (#483) * OR-346: Route Windows local runs' python around the Store alias stub Git Bash resolved bare `python` to the Microsoft Store App Execution Alias, which exits 9009 (49 to bash) and writes nothing to the run log. run.sh now puts a real interpreter first on PATH (next on PATH, else `py`'s), forwards a stub `python3` to `python`, and otherwise fails with a clear message. Co-Authored-By: Claude Opus 5.5 * Test that Git Bash executes the generated python shims Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- src/jobs/localbox.rs | 9 +- src/jobs/localbox/python.rs | 193 ++++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 src/jobs/localbox/python.rs diff --git a/src/jobs/localbox.rs b/src/jobs/localbox.rs index 07ec876f..15273b4c 100644 --- a/src/jobs/localbox.rs +++ b/src/jobs/localbox.rs @@ -15,6 +15,8 @@ use std::path::{Path, PathBuf}; use crate::error::{anyhow, Result}; use crate::jobs::ssh::{sh_quote, JobState}; +#[cfg(windows)] +mod python; #[cfg(windows)] mod windows; @@ -49,7 +51,7 @@ pub fn run_job(spec: &LocalJobSpec) -> Result { let env = super::default_python_env(&spec.env); #[cfg(not(windows))] // Keep the launcher identifiable until background children exit, including after a payload cd. - let completion = format!( + let prelude = format!( "trap 'exit 143' TERM\ntrap {} EXIT\n", sh_quote(&format!( "code=$?; wait; echo \"$code\" > {}; exit \"$code\"", @@ -57,13 +59,14 @@ pub fn run_job(spec: &LocalJobSpec) -> Result { )) ); #[cfg(windows)] - let completion = ""; + let prelude = python::prelude(&dir) + .map_err(|e| anyhow!("Could not set up Python for {}: {}", dir.display(), e))?; #[cfg(not(windows))] let record_exit = ""; #[cfg(windows)] let record_exit = "echo $? > exit_code\n"; let run_sh = format!( - "#!/usr/bin/env bash\ncd {dir} || exit 97\n(\n{completion}{script}\n) > log 2>&1\n{record_exit}", + "#!/usr/bin/env bash\ncd {dir} || exit 97\n(\n{prelude}{script}\n) > log 2>&1\n{record_exit}", dir = sh_quote(&crate::local::bash::bash_path(&dir)), script = spec.script, ); diff --git a/src/jobs/localbox/python.rs b/src/jobs/localbox/python.rs new file mode 100644 index 00000000..10059054 --- /dev/null +++ b/src/jobs/localbox/python.rs @@ -0,0 +1,193 @@ +//! Keeps a run's bare `python`/`python3` off the Microsoft Store stub, whose +//! 9009 exit reaches bash as 49 with nothing in the log. + +use std::ffi::OsStr; +use std::os::windows::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use crate::jobs::ssh::sh_quote; +use crate::local::bash::bash_path; + +const STUB_ERROR: &str = + "orx: Python on this machine resolves to the Microsoft Store alias, not an installed Python. \ + Install Python from https://www.python.org/downloads/windows/ with \"Add python.exe to PATH\" \ + checked, or turn off the python.exe and python3.exe entries under \ + \"Manage app execution aliases\" in Settings, then rerun."; + +/// Resolved per call, so a venv the run command activates (which has no `python3.exe`) wins. +const FORWARD_TO_PYTHON: &str = "#!/bin/sh\nexec python \"$@\"\n"; + +/// The run.sh line that routes whichever of `python`/`python3` hit the stub to +/// a real interpreter, or to a clear error. +pub(super) fn prelude(dir: &Path) -> std::io::Result { + let Some(path) = crate::local::shell_env::search_path() else { + return Ok(String::new()); + }; + let python = first_on_path(&path, "python"); + let python_stub = python.as_deref().is_some_and(is_store_stub); + let python3_stub = first_on_path(&path, "python3") + .as_deref() + .is_some_and(is_store_stub); + if !python_stub && !python3_stub { + return Ok(String::new()); + } + let python_ok = python.is_some() && !python_stub; + let mut entries = Vec::new(); + let mut shims = Vec::new(); + if !python_ok { + match real_python(&path).as_deref().and_then(Path::parent) { + // On PATH rather than shimmed, so native children spawning `python` find it too. + Some(home) => entries.extend( + [home.to_path_buf(), home.join("Scripts")] + .into_iter() + .filter(|dir| dir.is_dir()), + ), + None => shims.push(("python", stub_shim())), + } + } + if python3_stub { + shims.push(("python3", FORWARD_TO_PYTHON.to_string())); + } + if !shims.is_empty() { + let shim_dir = dir.join("python-shims"); + std::fs::create_dir_all(&shim_dir)?; + for (name, script) in shims { + std::fs::write(shim_dir.join(name), script)?; + } + entries.push(shim_dir); + } + let entries: Vec = entries + .iter() + .map(|dir| sh_quote(&bash_path(dir))) + .collect(); + Ok(format!("export PATH={}:\"$PATH\"\n", entries.join(":"))) +} + +fn stub_shim() -> String { + format!("#!/bin/sh\necho {} >&2\nexit 127\n", sh_quote(STUB_ERROR)) +} + +/// What bash tries for `name`, in order: the extensionless file (pyenv-win's shim) before `.exe`. +fn candidates<'a>(path: &'a OsStr, name: &'static str) -> impl Iterator + 'a { + std::env::split_paths(path) + .filter(|dir| dir.is_absolute()) + .flat_map(move |dir| [dir.join(name), dir.join(format!("{name}.exe"))]) + .filter(|candidate| exists(candidate)) +} + +fn first_on_path(path: &OsStr, name: &'static str) -> Option { + candidates(path, name).next() +} + +/// The first `python` past the stub, else the one `py` picks for installs +/// that skipped "Add python.exe to PATH". +fn real_python(path: &OsStr) -> Option { + candidates(path, "python") + .find(|candidate| !in_windows_apps(candidate)) + .or_else(py_launcher_python) +} + +fn py_launcher_python() -> Option { + let py = crate::local::shell_env::find_on_path("py")?; + let out = hidden(&py) + .args(["-3", "-c", "import sys; print(sys.executable)"]) + .env("PYTHONUTF8", "1") + .output() + .ok() + .filter(|out| out.status.success())?; + let python = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim()); + exists(&python).then_some(python) +} + +/// A real Store install also lives in WindowsApps; only the stub exits 9009. +fn is_store_stub(python: &Path) -> bool { + in_windows_apps(python) + && hidden(python) + .arg("--version") + .stdout(Stdio::null()) + .status() + .is_ok_and(|status| status.code() == Some(9009)) +} + +fn in_windows_apps(path: &Path) -> bool { + path.to_string_lossy() + .to_ascii_lowercase() + .replace('/', "\\") + .contains(r"\microsoft\windowsapps\") +} + +/// The Store alias is a reparse point that cannot be followed, so `is_file` misses it. +fn exists(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|meta| !meta.is_dir()) +} + +fn hidden(program: &Path) -> Command { + let mut command = Command::new(program); + command + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW); + command +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_stub_shim_explains_and_exits_127() { + let script = stub_shim(); + assert!(script.contains("Microsoft Store alias")); + assert!(script.ends_with(">&2\nexit 127\n")); + } + + #[test] + fn git_bash_runs_the_shims_and_python3_reaches_the_error() { + let dir = std::env::temp_dir().join(format!("orx-shims-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("python"), stub_shim()).unwrap(); + std::fs::write(dir.join("python3"), FORWARD_TO_PYTHON).unwrap(); + let mut path = std::ffi::OsString::from(&dir); + if let Some(rest) = crate::local::shell_env::search_path() { + path.push(";"); + path.push(rest); + } + let out = Command::new(crate::local::bash::program()) + .args(["-c", "python3 --version"]) + .env("PATH", path) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(127)); + assert!(String::from_utf8_lossy(&out.stderr).contains("Microsoft Store alias")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn a_forward_slashed_path_entry_is_still_windows_apps() { + assert!(in_windows_apps(Path::new( + r"C:/Users/me/AppData/Local/Microsoft/WindowsApps\python.exe" + ))); + assert!(!in_windows_apps(Path::new(r"C:\Python312\python.exe"))); + } + + #[test] + fn bash_resolution_prefers_an_extensionless_shim() { + let root = std::env::temp_dir().join(format!("orx-python-{}", uuid::Uuid::new_v4())); + let (pyenv, python_org) = (root.join("pyenv"), root.join("python-org")); + std::fs::create_dir_all(&pyenv).unwrap(); + std::fs::create_dir_all(&python_org).unwrap(); + std::fs::write(pyenv.join("python"), "").unwrap(); + std::fs::write(pyenv.join("python.exe"), "").unwrap(); + std::fs::write(python_org.join("python.exe"), "").unwrap(); + let path = std::env::join_paths([&python_org, &pyenv]).unwrap(); + assert_eq!( + first_on_path(&path, "python"), + Some(python_org.join("python.exe")) + ); + let path = std::env::join_paths([&pyenv, &python_org]).unwrap(); + assert_eq!(first_on_path(&path, "python"), Some(pyenv.join("python"))); + assert_eq!(first_on_path(&path, "python3"), None); + std::fs::remove_dir_all(root).unwrap(); + } +}