From 9c958c266f9b33c7dea0d5fe47d4dfe6f3f0e452 Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:29:18 -0700 Subject: [PATCH 01/12] fix: Windows Job Object process linkage for tool subprocess isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GAP-013, GAP-024, GAP-028: Windows has no parent-death signal (no PR_SET_PDEATHSIG equivalent), so a tool subprocess spawned here survives a parent killed outright — crash, taskkill /F on one PID, a supervisor killing only the parent. Proven with live bash-tool subprocess in flight: killing *only* the top-level amplifier.exe PID left the wsl.exe → wsl.exe → wslhost.exe chain alive at all 18 polls from t+3s through t+60.8s. Initial Job Object implementation only captured the immediate wsl.exe launcher, not its descendants (proven via IsProcessInJob). Fixes: - Lazily-created Windows Job Object (CreateJobObjectW + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, via ctypes, no new dependency) assigned to every foreground tool subprocess. Kernel tears the job down the instant the handle closes for *any* reason — crash, signal, external kill, etc. - CreateToolhelp32Snapshot process-tree walk assigning every descendant to the job, not just the launcher. Proof: before → target PIDs alive at all 18 checks; after → zero alive at first check (t+3s). Verified against genuine uncaught exception too: top-level process crashed with real traceback, all three wsl-family PIDs dead at first check. Regression test (test_gap013_windows_job_object_orphans.py) proven to have teeth: no-op'ing _assign_to_windows_job → fails with "grandchild process survived"; restore → 3/3 pass. Regression: Linux 59 passed / 10 skipped (all Windows-only guards); macOS 58 passed / 1 pre-existing unrelated failure. Pure no-op off Windows, confirmed by code inspection and all skips. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- amplifier_module_tool_bash/__init__.py | 318 +++++++++++++++++ pyproject.toml | 8 + .../test_gap013_windows_job_object_orphans.py | 326 ++++++++++++++++++ uv.lock | 318 +++++++++++++++++ 4 files changed, 970 insertions(+) create mode 100644 tests/test_gap013_windows_job_object_orphans.py diff --git a/amplifier_module_tool_bash/__init__.py b/amplifier_module_tool_bash/__init__.py index 765d0a3..ed3e187 100644 --- a/amplifier_module_tool_bash/__init__.py +++ b/amplifier_module_tool_bash/__init__.py @@ -94,6 +94,310 @@ def _signal_pids(pids: set[int], sig: int) -> None: pass +# --- Windows orphan prevention (GAP-024) --------------------------------- +# +# On POSIX, `_run_command`'s existing timeout-cleanup path (`os.killpg` + +# `_find_descendant_pids`) only covers the case where THIS module's own code +# is still running to execute that cleanup -- e.g. the tool-level timeout +# firing, or a normal asyncio.CancelledError propagating through a still- +# alive event loop. It does NOT cover the host `amplifier.exe` process being +# killed outright (crash, `Stop-Process`/`taskkill /F` on just the top PID, +# a supervisor terminating only the parent) -- Windows has no equivalent of +# POSIX's parent-death signal (`prctl(PR_SET_PDEATHSIG)`), so a subprocess +# spawned here has no way to notice its parent is gone and no code of ours +# runs to clean it up. +# +# Confirmed empirically (adversarial Windows re-test, alienware-r13): +# spawning `sleep 60` via this module's WSL-routed path +# (`wsl --exec bash -c ...`), then killing ONLY the top-level `amplifier.exe` +# PID (no /T, no console Ctrl+C -- a plain `Stop-Process -Id `), left +# the resulting `wsl.exe -> wsl.exe -> wslhost.exe` chain running with a +# dead parent for the entire 60+ second observation window. This directly +# contradicts the prior claim that killing only the top-level PID always +# brings the whole tree down within ~2s -- that was true for amplifier's own +# internal python-to-python self-relaunch (which IS covered by an existing +# job-object association), but not for tool-call subprocesses spawned from +# deep inside a running turn, which were never assigned to that job. +# +# Fix: create one Windows Job Object per process, with +# JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE set, and assign every subprocess this +# module spawns (on the foreground/tracked path only -- NOT +# `_run_command_background`, whose whole point is to outlive us) to that +# job. The job's only handle lives in this process; when this process ends +# for ANY reason -- including a forceful kill that runs none of our own +# Python cleanup code -- the OS closes that handle and the kernel itself +# tears down every process still assigned to the job. This does not depend +# on any application code running, so it also covers crashes. +_windows_job_handle = None +_windows_job_lock = None + + +def _get_windows_job_object(): + """Lazily create (once per process) a Job Object with kill-on-close set. + + Returns the job handle (an int, per ctypes' ``wintypes.HANDLE``) or + ``None`` if creation failed for any reason -- callers must treat that as + "no extra protection available" and continue without raising, since + this is a defense-in-depth addition, not a required dependency for the + tool to function. + """ + global _windows_job_handle, _windows_job_lock + if sys.platform != "win32": + return None + if _windows_job_lock is None: + import threading + + _windows_job_lock = threading.Lock() + with _windows_job_lock: + if _windows_job_handle is not None: + return _windows_job_handle + try: + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + + job = kernel32.CreateJobObjectW(None, None) + if not job: + logger.debug( + "tool-bash: CreateJobObjectW failed (err=%s); " + "proceeding without orphan protection", + ctypes.get_last_error(), + ) + return None + + # JOBOBJECT_BASIC_LIMIT_INFORMATION + JOBOBJECT_EXTENDED_LIMIT_INFORMATION + # layout (winnt.h). We only need to set LimitFlags on the basic + # struct embedded at the start of the extended one. + JobObjectExtendedLimitInformation = 9 + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 + + class IO_COUNTERS(ctypes.Structure): + _fields_ = [ + ("ReadOperationCount", ctypes.c_uint64), + ("WriteOperationCount", ctypes.c_uint64), + ("OtherOperationCount", ctypes.c_uint64), + ("ReadTransferCount", ctypes.c_uint64), + ("WriteTransferCount", ctypes.c_uint64), + ("OtherTransferCount", ctypes.c_uint64), + ] + + class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure): + _fields_ = [ + ("PerProcessUserTimeLimit", ctypes.c_int64), + ("PerJobUserTimeLimit", ctypes.c_int64), + ("LimitFlags", wintypes.DWORD), + ("MinimumWorkingSetSize", ctypes.c_size_t), + ("MaximumWorkingSetSize", ctypes.c_size_t), + ("ActiveProcessLimit", wintypes.DWORD), + ("Affinity", ctypes.c_size_t), + ("PriorityClass", wintypes.DWORD), + ("SchedulingClass", wintypes.DWORD), + ] + + class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure): + _fields_ = [ + ("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION), + ("IoInfo", IO_COUNTERS), + ("ProcessMemoryLimit", ctypes.c_size_t), + ("JobMemoryLimit", ctypes.c_size_t), + ("PeakProcessMemoryUsed", ctypes.c_size_t), + ("PeakJobMemoryUsed", ctypes.c_size_t), + ] + + info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION() + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + + ok = kernel32.SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + ctypes.byref(info), + ctypes.sizeof(info), + ) + if not ok: + logger.debug( + "tool-bash: SetInformationJobObject failed (err=%s); " + "proceeding without orphan protection", + ctypes.get_last_error(), + ) + kernel32.CloseHandle(job) + return None + + _windows_job_handle = job + return job + except Exception as e: # pragma: no cover - defense in depth only + logger.debug( + "tool-bash: Windows job-object setup failed (%s); " + "proceeding without orphan protection", + e, + ) + return None + + +def _assign_to_windows_job(pid: int) -> bool: + """Best-effort: assign `pid` to this process's kill-on-close job object. + + Returns whether assignment actually succeeded. Most callers only need + "did I do my best" semantics and can ignore the return value; the + descendant-walker below (GAP-013/GAP-028) uses it to log clearly. + + Failure is intentionally swallowed as far as the CALLER's control flow + goes (logged at debug only) -- this is defense-in-depth cleanup, not a + correctness requirement for the command itself to run. A process that + can't be assigned (e.g. already exited, or running with different + privileges) just doesn't get the extra protection; it does not fail + the tool call. + """ + if sys.platform != "win32": + return False + job = _get_windows_job_object() + if job is None: + return False + try: + import ctypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + PROCESS_ALL_ACCESS = 0x1F0FFF + hproc = kernel32.OpenProcess(PROCESS_ALL_ACCESS, False, pid) + if not hproc: + logger.debug( + "tool-bash: OpenProcess(%s) failed (err=%s); pid not job-protected", + pid, + ctypes.get_last_error(), + ) + return False + try: + if not kernel32.AssignProcessToJobObject(job, hproc): + logger.debug( + "tool-bash: AssignProcessToJobObject(%s) failed (err=%s); " + "pid not job-protected", + pid, + ctypes.get_last_error(), + ) + return False + return True + finally: + kernel32.CloseHandle(hproc) + except Exception as e: # pragma: no cover - defense in depth only + logger.debug("tool-bash: failed to job-protect pid %s (%s)", pid, e) + return False + + +def _enumerate_child_pids_windows(parent_pid: int) -> set[int]: + """Direct children of `parent_pid` via CreateToolhelp32Snapshot -- a + plain Win32 API walk of the system-wide process snapshot, filtered by + th32ParentProcessID. No WMI/CIM, no PowerShell subprocess. + + Windows-only. Returns an empty set on any failure or on other platforms. + """ + if sys.platform != "win32": + return set() + try: + import ctypes + from ctypes import wintypes + + TH32CS_SNAPPROCESS = 0x00000002 + + class PROCESSENTRY32(ctypes.Structure): + _fields_ = [ + ("dwSize", wintypes.DWORD), + ("cntUsage", wintypes.DWORD), + ("th32ProcessID", wintypes.DWORD), + ("th32DefaultHeapID", ctypes.POINTER(ctypes.c_ulong)), + ("th32ModuleID", wintypes.DWORD), + ("cntThreads", wintypes.DWORD), + ("th32ParentProcessID", wintypes.DWORD), + ("pcPriClassBase", ctypes.c_long), + ("dwFlags", wintypes.DWORD), + ("szExeFile", ctypes.c_char * 260), + ] + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + snap = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) + if snap in (-1, 0): + return set() + try: + entry = PROCESSENTRY32() + entry.dwSize = ctypes.sizeof(PROCESSENTRY32) + children: set[int] = set() + if not kernel32.Process32First(snap, ctypes.byref(entry)): + return set() + while True: + if entry.th32ParentProcessID == parent_pid: + children.add(entry.th32ProcessID) + if not kernel32.Process32Next(snap, ctypes.byref(entry)): + break + return children + finally: + kernel32.CloseHandle(snap) + except Exception as e: # pragma: no cover - defense in depth only + logger.debug( + "tool-bash: descendant enumeration failed for %s (%s)", parent_pid, e + ) + return set() + + +async def _protect_windows_descendants(root_pid: int) -> None: + """Best-effort background task (GAP-013/GAP-028): assign every + descendant of `root_pid` to the same kill-on-close job object, not + just the immediate spawned PID. + + Why this exists: `_assign_to_windows_job(process.pid)` alone was found + NOT to actually protect a WSL-routed command's real descendants. + Verified directly against the deployed code with the Win32 + `IsProcessInJob` query (native Windows, alienware-r13): after spawning + `wsl --exec bash -c ` and calling `_assign_to_windows_job()` on + the immediate PID, that top-level PID *was* a job member -- but the + inner `wsl.exe` and `wslhost.exe` processes underneath it (the ones + that do the actual work, and the ones this module's own comments + elsewhere claim are covered) were NOT. Windows only auto-propagates + job membership to children spawned directly by a job-member process + via CreateProcess; WSL's inner process tree is connected to the outer + `wsl.exe` via an RPC/session channel rather than a plain parent-child + CreateProcess relationship, so it never inherits membership that way. + + An external kill of the top-level process was still observed (same + investigation) to bring the whole WSL tree down in practice -- but via + WSL's own connection-teardown behavior when its client disconnects, + not via the job object. That is a real, currently-working mechanism, + but it is undocumented, owned by WSL rather than by us, and not + something this module actually controls or could adjust if it ever + changed. This function makes the protection deliberate instead of + coincidental: it walks the process tree (root_pid's children, and + their children) with a few short retries -- the WSL tree takes a + moment to fully spawn -- and assigns every PID it finds to the job + too, so the kill-on-close guarantee no longer depends on an external, + unverified assumption about WSL's behavior. + + Fire-and-forget: runs concurrently with the command's own + process.communicate(), never blocks or delays the tool call, and + never raises (every failure path is caught and logged at debug only, + same contract as _assign_to_windows_job itself). + """ + if sys.platform != "win32": + return + try: + seen: set[int] = {root_pid} + for _ in range(8): # poll for up to ~2s while the tree spawns + frontier = list(seen) + new_found = False + for pid in frontier: + for child in _enumerate_child_pids_windows(pid): + if child not in seen: + seen.add(child) + _assign_to_windows_job(child) + new_found = True + if not new_found and len(seen) > 1: + break # tree grew at least once, then stopped changing + await asyncio.sleep(0.25) + except Exception as e: # pragma: no cover - defense in depth only + logger.debug( + "tool-bash: descendant job-protection sweep failed for %s (%s)", + root_pid, + e, + ) + + async def mount(coordinator: ModuleCoordinator, config: dict[str, Any] | None = None): """ Mount the bash tool. @@ -647,6 +951,17 @@ async def _run_command( stdin=asyncio.subprocess.DEVNULL, # Never hand the child our stdin cwd=self.working_dir, ) + # GAP-024: assign to a kill-on-close job object so this + # (and its wslhost.exe descendants) can't outlive an + # amplifier.exe that gets killed outright rather than + # cancelled through our own code. See helper docstring. + _assign_to_windows_job(process.pid) + # GAP-013/GAP-028: the immediate wsl.exe PID being a job + # member does NOT mean its real descendants (inner + # wsl.exe, wslhost.exe) are -- verified directly with + # IsProcessInJob. Sweep for them in the background; see + # _protect_windows_descendants docstring for why. + asyncio.create_task(_protect_windows_descendants(process.pid)) else: # Git Bash or other: Direct exec with [bash, -c, command] process = await asyncio.create_subprocess_exec( @@ -658,6 +973,8 @@ async def _run_command( stdin=asyncio.subprocess.DEVNULL, # Never hand the child our stdin cwd=self.working_dir, ) + _assign_to_windows_job(process.pid) # GAP-024, see above + asyncio.create_task(_protect_windows_descendants(process.pid)) else: # No bash found - fall back to limited cmd.exe behavior # Check for shell features that won't work in cmd.exe @@ -691,6 +1008,7 @@ async def _run_command( stderr=asyncio.subprocess.PIPE, cwd=self.working_dir, ) + _assign_to_windows_job(process.pid) # GAP-024, see helper docstring else: # Unix-like (Linux, macOS, WSL): Use real bash shell # This enables: diff --git a/pyproject.toml b/pyproject.toml index 7c71d40..304306a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,9 @@ build-backend = "hatchling.build" [tool.uv] package = true +[tool.uv.sources] +amplifier-core = { path = "../amplifier-core", editable = true } + [tool.hatch.build.targets.wheel] packages = [ "amplifier_module_tool_bash", @@ -36,3 +39,8 @@ testpaths = ["tests"] addopts = "--import-mode=importlib" asyncio_mode = "strict" +[dependency-groups] +dev = [ + "amplifier-core", +] + diff --git a/tests/test_gap013_windows_job_object_orphans.py b/tests/test_gap013_windows_job_object_orphans.py new file mode 100644 index 0000000..f36f558 --- /dev/null +++ b/tests/test_gap013_windows_job_object_orphans.py @@ -0,0 +1,326 @@ +"""Regression test: GAP-013 / GAP-024 -- a tool-call subprocess (and its own +descendants) must not outlive the host process, even when the host is +killed outright rather than shut down through this module's own code. + +## Why this test exists + +GAP-013 ("orphaned process trees left after amplifier exits") was first +retracted as a harness artifact -- the two experiments behind that +retraction never actually had a tool-call subprocess in flight at the +moment of the kill. An adversarial re-test built that missing state (a +live ``bash`` tool subprocess, then ``Stop-Process`` on *only* the +top-level PID -- no ``/T``, no console Ctrl+C) and found a real orphan: +Windows has no parent-death signal (no ``PR_SET_PDEATHSIG`` equivalent), +so a subprocess this module spawns has no way to notice its parent died, +and none of this module's own cleanup code runs to catch it. + +The fix (GAP-024) is a lazily-created Windows Job Object per process, +with ``JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`` set, and every foreground +subprocess this module spawns is assigned to it via +``_assign_to_windows_job``. The job's only handle lives in the host +process; when that process ends for ANY reason -- including a forceful +kill that runs none of this module's own Python -- the OS itself closes +the handle and the kernel tears down every process still assigned to the +job. This does not depend on any of our code running, so it covers +crashes too, not just graceful shutdown. + +Nothing previously locked this in as an automated regression -- the only +proof was a one-shot manual test against a specific process tree on a +specific box. If the job-object wiring regresses (the assignment call is +dropped from a new/changed subprocess-spawn site, ``AssignProcessToJobObject`` +silently starts failing, or ``JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`` is +accidentally cleared), this test is what catches it. + +## Design + +This exercises the REAL ``_get_windows_job_object`` / ``_assign_to_windows_job`` +functions from ``amplifier_module_tool_bash`` -- not a reimplementation -- +against a small, deterministic two-level process tree, rather than +depending on WSL/bash being installed on whatever box runs this suite +(the real GAP-013 scenario happened to go through a WSL-routed ``bash`` +tool call, but the fix and the guarantee it protects are generic to every +foreground subprocess this module spawns). + +* A *harness* process stands in for the host (``amplifier.exe``): it + spawns a *grandchild* process, assigns the grandchild to a Job Object + using this module's own real functions, and writes PID markers for + both. +* **Case (a) -- normal completion:** the grandchild is short-lived, the + harness waits for it and then exits on its own. Asserts nothing is left + running -- the job-object wiring must not itself cause anything to + linger under the ordinary, successful-completion path. +* **Case (b) -- abnormal external kill:** the grandchild is long-lived, + and once both are confirmed alive, the test kills ONLY the harness PID + (``taskkill /F /PID`` -- deliberately no ``/T``, mirroring + ``Stop-Process -Id`` with no children flag, the exact case the original + false retraction never tested). Asserts the grandchild dies anyway, + without anything explicitly killing it -- proving the OS-level + kill-on-close mechanism, not just a Python cleanup path that happens to + run. +""" + +from __future__ import annotations + +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +import amplifier_module_tool_bash as tool_bash_pkg + +pytestmark = pytest.mark.skipif( + sys.platform != "win32", + reason="GAP-013/024 orphan-prevention is a Windows-only mechanism (Job Objects)", +) + + +_GRANDCHILD_SCRIPT = """ +import sys +import os +import time +from pathlib import Path + +marker_dir = Path(sys.argv[1]) +sleep_s = float(sys.argv[2]) + +(marker_dir / "grandchild_started.pid").write_text(str(os.getpid())) +time.sleep(sleep_s) +(marker_dir / "grandchild_finished.marker").write_text("done") +""" + +# The harness stands in for "amplifier.exe": it spawns a grandchild and +# assigns it to a kill-on-close Job Object using the module's REAL +# functions (imported, not reimplemented), then either waits for a short +# grandchild to finish on its own (case a) or sleeps indefinitely, +# simulating a live process to be killed externally (case b). +_HARNESS_SCRIPT = """ +import sys +import os +import subprocess +import time +from pathlib import Path + +mode = sys.argv[1] +marker_dir = Path(sys.argv[2]) +grandchild_script = sys.argv[3] +grandchild_sleep_s = sys.argv[4] + +from amplifier_module_tool_bash import _assign_to_windows_job + +grandchild = subprocess.Popen( + [sys.executable, grandchild_script, str(marker_dir), grandchild_sleep_s] +) +_assign_to_windows_job(grandchild.pid) + +(marker_dir / "harness.pid").write_text(str(os.getpid())) +(marker_dir / "grandchild.pid").write_text(str(grandchild.pid)) + +if mode == "normal": + grandchild.wait() + # Falls off the end and exits cleanly right after its own child does -- + # this is the ordinary "tool call completed" path. +elif mode == "abnormal": + # Simulates a live amplifier.exe mid-turn. Deliberately never exits on + # its own -- the test kills it externally to exercise the job-object + # kill-on-close path, not a graceful shutdown. + time.sleep(120) +""" + + +def _pid_alive(pid: int) -> bool: + """Best-effort Windows liveness check via ``tasklist`` (no admin needed).""" + result = subprocess.run( + ["tasklist", "/FI", f"PID eq {pid}"], + capture_output=True, + text=True, + check=False, + ) + return str(pid) in result.stdout + + +def _wait_for_marker(path: Path, timeout_s: float = 10.0) -> str: + """Poll for a marker file to appear with non-empty content.""" + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if path.exists(): + text = path.read_text().strip() + if text: + return text + time.sleep(0.1) + raise TimeoutError(f"marker file {path} never appeared with content") + + +def _force_kill(pid: int) -> None: + """Best-effort cleanup so a failing test never leaks a stand-in process.""" + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(pid)], + capture_output=True, + check=False, + ) + + +def _write_scripts(tmp_path: Path) -> tuple[Path, Path]: + harness_script = tmp_path / "gap013_harness.py" + grandchild_script = tmp_path / "gap013_grandchild.py" + harness_script.write_text(_HARNESS_SCRIPT) + grandchild_script.write_text(_GRANDCHILD_SCRIPT) + return harness_script, grandchild_script + + +class TestGap013NormalCompletionLeavesNoOrphans: + """Case (a): ordinary, successful completion -- nothing left behind.""" + + def test_normal_completion_no_surviving_descendants(self, tmp_path: Path) -> None: + harness_script, grandchild_script = _write_scripts(tmp_path) + + harness = subprocess.Popen( + [ + sys.executable, + str(harness_script), + "normal", + str(tmp_path), + str(grandchild_script), + "2", # short-lived grandchild: 2s + ] + ) + + harness_pid: int | None = None + grandchild_pid: int | None = None + try: + grandchild_pid = int(_wait_for_marker(tmp_path / "grandchild.pid")) + harness_pid = int(_wait_for_marker(tmp_path / "harness.pid")) + + # Bounded wait for the harness to exit on its own. Budget: + # ~2s grandchild sleep + interpreter startup/shutdown overhead + # on both processes. 20s gives generous headroom without + # masking a real hang. + deadline = time.monotonic() + 20 + while time.monotonic() < deadline and harness.poll() is None: + time.sleep(0.2) + + assert harness.poll() is not None, ( + "harness process never exited on its own within 20s during " + "normal completion -- unrelated to GAP-013, but a real " + "regression in the stand-in harness or the module import" + ) + + # Give the OS a brief moment to fully reap/update process state. + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and _pid_alive(grandchild_pid): + time.sleep(0.2) + + assert not _pid_alive(grandchild_pid), ( + f"grandchild process (pid {grandchild_pid}) was still alive " + "after the harness completed normally -- normal-completion " + "cleanup regressed" + ) + assert not _pid_alive(harness_pid), ( + f"harness process (pid {harness_pid}) reported exited but " + "is still visible in the process table" + ) + finally: + if harness.poll() is None: + harness.kill() + if harness_pid is not None: + _force_kill(harness_pid) + if grandchild_pid is not None: + _force_kill(grandchild_pid) + + +class TestGap013ExternalKillStillReapsDescendantViaJobObject: + """Case (b): the exact state the original false retraction never + tested -- external kill of ONLY the top-level PID, no /T, no console + Ctrl+C.""" + + def test_kill_top_level_pid_only_still_kills_grandchild( + self, tmp_path: Path + ) -> None: + harness_script, grandchild_script = _write_scripts(tmp_path) + + harness = subprocess.Popen( + [ + sys.executable, + str(harness_script), + "abnormal", + str(tmp_path), + str(grandchild_script), + "120", # long-lived grandchild: would run for 2 minutes + # unassisted -- only the job-object kill-on-close should + # end it early. + ] + ) + + harness_pid: int | None = None + grandchild_pid: int | None = None + try: + grandchild_pid = int(_wait_for_marker(tmp_path / "grandchild.pid")) + harness_pid = int(_wait_for_marker(tmp_path / "harness.pid")) + _wait_for_marker(tmp_path / "grandchild_started.pid") + + assert _pid_alive(harness_pid), "harness never actually started" + assert _pid_alive(grandchild_pid), ( + "grandchild never actually started -- can't test whether " + "killing the harness reaps it" + ) + + # Kill ONLY the top-level harness PID. Deliberately no /T (no + # process-tree flag) and no console Ctrl+C -- this is the exact + # state ("Stop-Process -Id ", no children flag) that the + # original GAP-013 investigation's false retraction never + # exercised. + kill_result = subprocess.run( + ["taskkill", "/F", "/PID", str(harness_pid)], + capture_output=True, + text=True, + check=False, + ) + assert kill_result.returncode == 0, ( + f"failed to kill harness pid {harness_pid} for the test " + f"itself: {kill_result.stderr}" + ) + + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and _pid_alive(harness_pid): + time.sleep(0.2) + assert not _pid_alive(harness_pid), ( + "harness process survived being killed -- broken test " + "setup, not a GAP-013 finding" + ) + + # The grandchild has NOTHING killing it directly -- only the + # Job Object's kill-on-close semantics, triggered by Windows + # closing the harness's job handle when that process ends, + # should bring it down. Bound: this is an OS-level handle-close + # notification, not anything involving network/auth variance, + # so it should be near-instant -- but 15s gives real headroom + # above what a loaded CI box might need for the kernel to + # process the handle closure and for tasklist to reflect it. + deadline = time.monotonic() + 15 + while time.monotonic() < deadline and _pid_alive(grandchild_pid): + time.sleep(0.2) + + assert not _pid_alive(grandchild_pid), ( + f"grandchild process (pid {grandchild_pid}) survived the " + "external kill of ONLY the top-level harness PID -- this " + "is GAP-013's exact orphan signature. The Job Object " + "kill-on-close wiring (_assign_to_windows_job / GAP-024) " + "regressed." + ) + finally: + if harness.poll() is None: + harness.kill() + if harness_pid is not None: + _force_kill(harness_pid) + if grandchild_pid is not None: + _force_kill(grandchild_pid) + + +def test_module_exposes_the_functions_this_test_depends_on() -> None: + """Sanity check: fail loudly and clearly if a refactor renames/removes + the real functions this test imports inside the harness subprocess, + rather than surfacing as a confusing harness-side ImportError deep in + a subprocess with no visible traceback.""" + assert hasattr(tool_bash_pkg, "_assign_to_windows_job") + assert hasattr(tool_bash_pkg, "_get_windows_job_object") diff --git a/uv.lock b/uv.lock index f4ccabf..22be4d0 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,325 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "amplifier-core" +version = "1.6.0" +source = { editable = "../amplifier-core" } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tomli" }, + { name = "typing-extensions" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.3.1" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "tomli", specifier = ">=2.0" }, + { name = "typing-extensions", specifier = ">=4.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "grpcio", specifier = ">=1.60" }, + { name = "grpcio-tools", specifier = ">=1.60" }, + { name = "maturin", specifier = ">=1.9" }, + { name = "protobuf", specifier = ">=5.0" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, +] + [[package]] name = "amplifier-module-tool-bash" version = "1.0.0" source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "amplifier-core" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "amplifier-core", editable = "../amplifier-core" }] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] From 8135fe34b266e41496237b41ce8e40694f4cacca Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:09:01 -0700 Subject: [PATCH 02/12] build: fix portability and test suite visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove local editable path to amplifier-core in favour of canonical git+https source — the local path rendered the repo uninstallable for any user or system outside the authoring workspace. Add missing pytest and pytest-asyncio to dev dependency group. The suite was silently falling through to a globally-installed pytest outside the venv, masking 6 collection errors. Proof: Clean clone at /tmp with no ../amplifier-core sibling: Before: uv build fails with 'Distribution not found' After: uv build succeeds, 59 passed, 10 skipped 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- pyproject.toml | 5 ++- uv.lock | 102 +++++++++++++++++++++++++++++++++++++------------ 2 files changed, 80 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 304306a..36576f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ build-backend = "hatchling.build" package = true [tool.uv.sources] -amplifier-core = { path = "../amplifier-core", editable = true } +amplifier-core = { git = "https://github.com/microsoft/amplifier-core", branch = "main" } [tool.hatch.build.targets.wheel] packages = [ @@ -42,5 +42,6 @@ asyncio_mode = "strict" [dependency-groups] dev = [ "amplifier-core", + "pytest>=8", + "pytest-asyncio>=0.24", ] - diff --git a/uv.lock b/uv.lock index 22be4d0..38a622f 100644 --- a/uv.lock +++ b/uv.lock @@ -4,8 +4,8 @@ requires-python = ">=3.11" [[package]] name = "amplifier-core" -version = "1.6.0" -source = { editable = "../amplifier-core" } +version = "1.6.1" +source = { git = "https://github.com/microsoft/amplifier-core?branch=main#92b339ab3cfe13241c1551f8c5d06dfe85806888" } dependencies = [ { name = "click" }, { name = "pydantic" }, @@ -14,25 +14,6 @@ dependencies = [ { name = "typing-extensions" }, ] -[package.metadata] -requires-dist = [ - { name = "click", specifier = ">=8.3.1" }, - { name = "pydantic", specifier = ">=2.0" }, - { name = "pyyaml", specifier = ">=6.0.3" }, - { name = "tomli", specifier = ">=2.0" }, - { name = "typing-extensions", specifier = ">=4.0" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "grpcio", specifier = ">=1.60" }, - { name = "grpcio-tools", specifier = ">=1.60" }, - { name = "maturin", specifier = ">=1.9" }, - { name = "protobuf", specifier = ">=5.0" }, - { name = "pytest", specifier = ">=8.4.2" }, - { name = "pytest-asyncio", specifier = ">=1.3.0" }, -] - [[package]] name = "amplifier-module-tool-bash" version = "1.0.0" @@ -41,12 +22,18 @@ source = { editable = "." } [package.dev-dependencies] dev = [ { name = "amplifier-core" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [{ name = "amplifier-core", editable = "../amplifier-core" }] +dev = [ + { name = "amplifier-core", git = "https://github.com/microsoft/amplifier-core?branch=main" }, + { name = "pytest", specifier = ">=8" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, +] [[package]] name = "annotated-types" @@ -78,6 +65,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -195,6 +209,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -315,12 +367,12 @@ wheels = [ [[package]] name = "typing-inspection" -version = "0.4.2" +version = "0.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/bc/4eae18cd40c65798a16267572ba346c11f599d44b01603dbd843342042bc/typing_inspection-0.4.3.tar.gz", hash = "sha256:c5f9ec1530b5c1e2c9bc34a84d9a3466ed1b2f3f2fa9f901368d9c5596210e4d", size = 76711, upload-time = "2026-08-10T09:39:18.063Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, + { url = "https://files.pythonhosted.org/packages/42/f7/7a3935abdebd5cf18705a5f0335dd6a3a18bef3baa7cb9edc3b6b9922cc8/typing_inspection-0.4.3-py3-none-any.whl", hash = "sha256:5f42b23858a91e0b4ef521f5418f03a0da3c9216fd2995ef5e73463100e676cd", size = 14693, upload-time = "2026-08-10T09:39:16.693Z" }, ] From 70eb437699e8ef006bf73b7072bf6bd68486c052 Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:07:45 -0700 Subject: [PATCH 03/12] fix: harden Windows job protection against inert failures and refactor sweeps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from independent adversarial review of PR #15: 1. CRITICAL — protection could silently fail with zero signal. All three call sites discarded _assign_to_windows_job()'s return value, and failures logged at debug only. AssignProcessToJobObject fails in GitHub Actions, Windows containers, and under some EDR agents — the normal state in many environments. Protection never engaged but commands still succeeded. Now reported once per process at warning level via _report_windows_job_failure(), plainly stating protection is NOT active and why. Environmental cause is constant, so once per process avoids training people to ignore repeated messages. 2. HIGH — no test drove actual call sites. Existing GAP-013 test called _assign_to_windows_job directly, bypassing _run_command, and was Windows- gated in a CI-less repo (ran nowhere automatically). Refactor dropping one call site would have kept suite green. Added tests/test_windows_job_call_sites.py which patches sys.platform and both helpers, driving real _run_command and asserting it job-assigns the spawned PID. Runs on every platform, needs no Windows kernel. 3. MEDIUM — fire-and-forget asyncio.create_task held no reference. Stdlib docs explicit: unreferenced tasks may garbage-collect before done. These sweeps are the entire GAP-013/GAP-028 protection for WSL descendants. Collected task looks identical to silent assignment failure. Now via _spawn_descendant_sweep() holding refs in module-level set with done-callback for cleanup. 4. MEDIUM — no restype/argtypes on HANDLE-returning Win32 calls. ctypes defaults undeclared return to c_int (32-bit), truncating 64-bit HANDLE. Win32 guarantees handles are 32-bit-significant so it worked, but relying on unstated guarantee is how corruption bugs get in. Declared on CreateJobObjectW, SetInformationJobObject, OpenProcess, AssignProcessToJobObject, CloseHandle. Error logs now use ctypes.WinError(...) instead of bare integer. 5. LOW — lazy threading.Lock() creation was itself unsynchronised. if _lock is None: _lock = Lock() lets two threads build two locks. Moved to module import. Benign vs environmental failure now distinguished: OpenProcess failure usually means process already exited (debug level), while AssignProcessToJobObject failure is environmental (warning once). Test results: 62 passed, 10 skipped (baseline 59 passed, 10 skipped). Delta is exactly 3 new contract tests. ruff check passed both before and after. POSIX safety via test assertion: with sys.platform patched to 'linux', neither _assign_to_windows_job nor _spawn_descendant_sweep is called — platform guard now test-enforced. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- amplifier_module_tool_bash/__init__.py | 149 +++++++++++++++++++++--- tests/test_windows_job_call_sites.py | 153 +++++++++++++++++++++++++ 2 files changed, 285 insertions(+), 17 deletions(-) create mode 100644 tests/test_windows_job_call_sites.py diff --git a/amplifier_module_tool_bash/__init__.py b/amplifier_module_tool_bash/__init__.py index ed3e187..4997b96 100644 --- a/amplifier_module_tool_bash/__init__.py +++ b/amplifier_module_tool_bash/__init__.py @@ -14,6 +14,7 @@ import signal import subprocess import sys +import threading from typing import Any from amplifier_core import ModuleCoordinator @@ -129,7 +130,61 @@ def _signal_pids(pids: set[int], sig: int) -> None: # tears down every process still assigned to the job. This does not depend # on any application code running, so it also covers crashes. _windows_job_handle = None -_windows_job_lock = None +# Created at import, not lazily. A `if _lock is None: _lock = Lock()` guard is +# itself unsynchronised -- two OS threads can both see None and build two locks, +# defeating the mutual exclusion it exists to provide. Nothing in this module +# currently calls off the event-loop thread, so this was latent, but a +# module-level construction costs nothing and removes the trap. +_windows_job_lock = threading.Lock() + +# Background descendant-sweep tasks, held so the event loop cannot garbage +# collect them mid-flight. asyncio.create_task returns a task that is only +# weakly referenced by the loop; the docs are explicit that an unreferenced task +# "may get garbage collected at any time, even before it's done". These sweeps +# are the entire GAP-013/GAP-028 protection for WSL descendants, and if one +# vanished the symptom would be indistinguishable from a silent assignment +# failure. +_windows_sweep_tasks: set = set() + +# One-shot flag so a job-object failure is reported loudly ONCE per process +# rather than either spamming every command or (as before) being invisible. +_windows_job_failure_reported = False + + +def _report_windows_job_failure(message: str, *args) -> None: + """Report a job-object failure ONCE per process, at warning level. + + These failures used to be logged at debug only and their return values + discarded by every caller, so the entire orphan-protection mechanism could + be inert with no operator-visible signal at all. + + That matters most in exactly the environments this protection is for. + ``AssignProcessToJobObject`` fails when the process is already inside a job + that disallows the assignment -- the normal state under CI runners (GitHub + Actions wraps every step in a job object), Windows containers, and some + endpoint-security agents. In those environments this ships, every command + still "succeeds", and nothing anywhere indicates the protection never + engaged. A later orphan report would then be wrongly dismissed as + already-fixed. + + Warning rather than error because the tool call itself is unaffected -- + this is defense-in-depth, not a correctness requirement. Once per process + rather than per command because the cause is environmental and constant; + repeating it every command would be noise that trains people to ignore it. + """ + global _windows_job_failure_reported + if _windows_job_failure_reported: + logger.debug("tool-bash: " + message, *args) + return + _windows_job_failure_reported = True + logger.warning( + "tool-bash: Windows orphan protection is NOT active for this process. " + + message + + ". Subprocesses spawned by this tool may survive an abrupt exit. " + "This is expected inside a restrictive parent job object (CI runners, " + "Windows containers); it is reported once per process.", + *args, + ) def _get_windows_job_object(): @@ -157,12 +212,23 @@ def _get_windows_job_object(): kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + # Declare signatures rather than letting ctypes default an + # undeclared return to c_int (32-bit signed), which truncates a + # 64-bit HANDLE. Win32 guarantees handle values are 32-bit + # significant, so the untyped form happens to work -- but relying on + # an unstated guarantee is how a silent, platform-specific + # corruption bug gets in. + kernel32.CreateJobObjectW.restype = wintypes.HANDLE + kernel32.CreateJobObjectW.argtypes = [wintypes.LPVOID, wintypes.LPCWSTR] + kernel32.SetInformationJobObject.restype = wintypes.BOOL + kernel32.CloseHandle.restype = wintypes.BOOL + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + job = kernel32.CreateJobObjectW(None, None) if not job: - logger.debug( - "tool-bash: CreateJobObjectW failed (err=%s); " - "proceeding without orphan protection", - ctypes.get_last_error(), + _report_windows_job_failure( + "CreateJobObjectW failed (%s)", + ctypes.WinError(ctypes.get_last_error()), ) return None @@ -215,10 +281,9 @@ class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure): ctypes.sizeof(info), ) if not ok: - logger.debug( - "tool-bash: SetInformationJobObject failed (err=%s); " - "proceeding without orphan protection", - ctypes.get_last_error(), + _report_windows_job_failure( + "SetInformationJobObject failed (%s)", + ctypes.WinError(ctypes.get_last_error()), ) kernel32.CloseHandle(job) return None @@ -256,23 +321,49 @@ def _assign_to_windows_job(pid: int) -> bool: try: import ctypes + from ctypes import wintypes + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.OpenProcess.restype = wintypes.HANDLE + kernel32.OpenProcess.argtypes = [ + wintypes.DWORD, + wintypes.BOOL, + wintypes.DWORD, + ] + kernel32.AssignProcessToJobObject.restype = wintypes.BOOL + kernel32.AssignProcessToJobObject.argtypes = [ + wintypes.HANDLE, + wintypes.HANDLE, + ] + kernel32.CloseHandle.restype = wintypes.BOOL + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + PROCESS_ALL_ACCESS = 0x1F0FFF hproc = kernel32.OpenProcess(PROCESS_ALL_ACCESS, False, pid) if not hproc: + # Debug, not warning: the overwhelmingly common cause is that the + # process already exited between spawn and assignment, which is + # benign and expected for fast commands. Distinct from the + # environmental failures reported once at warning level. logger.debug( - "tool-bash: OpenProcess(%s) failed (err=%s); pid not job-protected", + "tool-bash: OpenProcess(%s) failed (%s); pid not job-protected " + "(usually means it already exited)", pid, - ctypes.get_last_error(), + ctypes.WinError(ctypes.get_last_error()), ) return False try: if not kernel32.AssignProcessToJobObject(job, hproc): - logger.debug( - "tool-bash: AssignProcessToJobObject(%s) failed (err=%s); " - "pid not job-protected", + # This one IS environmental: the dominant cause is that this + # process already sits inside a job object that disallows the + # assignment -- the normal state under CI runners, Windows + # containers, and some endpoint-security agents. Report it + # loudly once, because in that case the protection is inert for + # every command and nothing else would ever say so. + _report_windows_job_failure( + "AssignProcessToJobObject(pid=%s) failed (%s)", pid, - ctypes.get_last_error(), + ctypes.WinError(ctypes.get_last_error()), ) return False return True @@ -337,6 +428,30 @@ class PROCESSENTRY32(ctypes.Structure): return set() +def _spawn_descendant_sweep(root_pid: int) -> None: + """Start the descendant sweep and KEEP A REFERENCE to the task. + + ``asyncio.create_task`` returns a task the loop holds only weakly. The + stdlib docs are explicit: "Save a reference to the result of this + function... A task that isn't referenced elsewhere may get garbage + collected at any time, even before it's done." + + These sweeps are the whole GAP-013/GAP-028 protection for WSL descendants + (inner ``wsl.exe``, ``wslhost.exe``), which do NOT inherit job membership + from the immediate spawned PID. If one were collected mid-flight, the + symptom would be orphaned processes with nothing in the logs -- outwardly + identical to a silent job-assignment failure, and correspondingly awful to + diagnose. + + In practice the task is always parked on ``asyncio.sleep``, so the loop's + timer structures probably keep it reachable. "Probably" is not a property + worth betting a process-cleanup guarantee on, and a set costs nothing. + """ + task = asyncio.create_task(_protect_windows_descendants(root_pid)) + _windows_sweep_tasks.add(task) + task.add_done_callback(_windows_sweep_tasks.discard) + + async def _protect_windows_descendants(root_pid: int) -> None: """Best-effort background task (GAP-013/GAP-028): assign every descendant of `root_pid` to the same kill-on-close job object, not @@ -961,7 +1076,7 @@ async def _run_command( # wsl.exe, wslhost.exe) are -- verified directly with # IsProcessInJob. Sweep for them in the background; see # _protect_windows_descendants docstring for why. - asyncio.create_task(_protect_windows_descendants(process.pid)) + _spawn_descendant_sweep(process.pid) else: # Git Bash or other: Direct exec with [bash, -c, command] process = await asyncio.create_subprocess_exec( @@ -974,7 +1089,7 @@ async def _run_command( cwd=self.working_dir, ) _assign_to_windows_job(process.pid) # GAP-024, see above - asyncio.create_task(_protect_windows_descendants(process.pid)) + _spawn_descendant_sweep(process.pid) else: # No bash found - fall back to limited cmd.exe behavior # Check for shell features that won't work in cmd.exe diff --git a/tests/test_windows_job_call_sites.py b/tests/test_windows_job_call_sites.py new file mode 100644 index 0000000..d640404 --- /dev/null +++ b/tests/test_windows_job_call_sites.py @@ -0,0 +1,153 @@ +"""Contract tests for Windows orphan-protection wiring. + +An independent review found the existing GAP-013 test drives +``_assign_to_windows_job`` **directly**, bypassing ``_run_command`` entirely. +That guards "does the raw ctypes wiring work" -- useful -- but not the contract +the module actually promises: *every foreground subprocess this module spawns +is assigned to the kill-on-close job*. If a refactor dropped one of the three +call sites, or added a fourth path without the call, that suite would still +pass. + +These tests close that gap, and deliberately run on **every** platform. The +Windows-only test file needs a Windows box and there is no Windows CI in this +repo, so its 3 tests execute nowhere automatically. Call-site wiring does not +need a real kernel to verify -- patch ``sys.platform`` and the two helpers, and +assert the real ``_run_command`` reaches them. That runs on Linux, on macOS, in +CI, today. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest +from amplifier_module_tool_bash import BashTool + + +class _FakeProc: + """Minimal stand-in for an asyncio subprocess.""" + + def __init__(self, pid: int = 424242) -> None: + self.pid = pid + self.returncode = 0 + + async def communicate(self, *_: Any, **__: Any) -> tuple[bytes, bytes]: + return (b"ok", b"") + + async def wait(self) -> int: + return 0 + + def kill(self) -> None: # pragma: no cover - not exercised here + pass + + +@pytest.mark.asyncio +async def test_windows_foreground_subprocess_is_job_assigned() -> None: + """The real _run_command must job-assign the PID it spawned. + + Patches only the platform and the two Win32 helpers -- the command-dispatch + logic under test is the genuine article. + """ + tool = BashTool({}) + proc = _FakeProc() + + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch( + "amplifier_module_tool_bash._assign_to_windows_job", MagicMock() + ) as assign, + patch("amplifier_module_tool_bash._spawn_descendant_sweep", MagicMock()), + patch( + "amplifier_module_tool_bash.asyncio.create_subprocess_exec", + AsyncMock(return_value=proc), + ), + patch( + "amplifier_module_tool_bash.asyncio.create_subprocess_shell", + AsyncMock(return_value=proc), + ), + patch("amplifier_module_tool_bash.shutil.which", return_value="C:\\bash.exe"), + ): + await tool._run_command("echo hi", timeout=5) + + assert assign.called, ( + "_run_command spawned a foreground subprocess on Windows without " + "assigning it to the kill-on-close job object. That is the entire " + "orphan protection this module promises." + ) + assert assign.call_args.args[0] == proc.pid, ( + f"job assignment used {assign.call_args.args[0]!r}, not the spawned " + f"pid {proc.pid!r}" + ) + + +@pytest.mark.asyncio +async def test_posix_never_touches_the_windows_helpers() -> None: + """POSIX must take a byte-identical path to what shipped before. + + The guard that matters most in this whole change: Linux, macOS and WSL + users currently work, and a Windows fix that reaches them is a net loss. + """ + tool = BashTool({}) + proc = _FakeProc() + + with ( + patch("amplifier_module_tool_bash.sys.platform", "linux"), + patch( + "amplifier_module_tool_bash._assign_to_windows_job", MagicMock() + ) as assign, + patch( + "amplifier_module_tool_bash._spawn_descendant_sweep", MagicMock() + ) as sweep, + patch( + "amplifier_module_tool_bash.asyncio.create_subprocess_exec", + AsyncMock(return_value=proc), + ), + patch( + "amplifier_module_tool_bash.asyncio.create_subprocess_shell", + AsyncMock(return_value=proc), + ), + ): + await tool._run_command("echo hi", timeout=5) + + assert not assign.called, ( + "a Windows job-object helper ran on POSIX -- the platform guard leaks" + ) + assert not sweep.called, ( + "the Windows descendant sweep ran on POSIX -- the platform guard leaks" + ) + + +def test_job_failure_is_reported_loudly_once_not_silently() -> None: + """An environmental job failure must be visible, and must not spam. + + Before this, every failure was ``logger.debug`` and every caller discarded + the return value, so the protection could be entirely inert under a + restrictive parent job object -- the normal state on CI runners and in + Windows containers -- with no operator-visible signal whatsoever. + """ + import amplifier_module_tool_bash as mod + + original = mod._windows_job_failure_reported + try: + mod._windows_job_failure_reported = False + with patch.object(mod, "logger") as log: + mod._report_windows_job_failure("first failure (%s)", "boom") + mod._report_windows_job_failure("second failure (%s)", "boom") + + assert log.warning.call_count == 1, ( + f"expected exactly one warning, got {log.warning.call_count}. " + "Zero means the failure is invisible; more than one means every " + "command spams and people learn to ignore it." + ) + assert log.debug.call_count == 1, ( + "subsequent failures should still be recorded at debug level" + ) + warned = log.warning.call_args.args[0] + assert "NOT active" in warned, ( + f"warning does not state the protection is inactive: {warned!r}" + ) + finally: + mod._windows_job_failure_reported = original From 8119f687f4f92af61235dfc97cead4f90e0f2b8c Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:43:22 -0700 Subject: [PATCH 04/12] test: add module-level POSIX platform skip to prevent import errors on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test file imports pty at module scope — a POSIX-only stdlib module with no Windows equivalent. On Windows, import raises during collection, surfacing as a hard ERROR. pytest.skip(..., allow_module_level=True) placed before the POSIX import prevents this. Previously, pytestmark guards were too late (evaluated after module body executes). Verified on POSIX (Linux aarch64, macOS arm64): - Full suite: 62 passed (Linux), 2 passed (macOS) Windows claim (intended: 1 error → 0) NOT verified — test machine went offline. Change is correct by inspection and matches pytest's documented mechanism. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- tests/test_foreground_stdin_isolation.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_foreground_stdin_isolation.py b/tests/test_foreground_stdin_isolation.py index 6fbf919..3ca11c9 100644 --- a/tests/test_foreground_stdin_isolation.py +++ b/tests/test_foreground_stdin_isolation.py @@ -38,6 +38,21 @@ is not covered by this pty-based test). """ +import sys + +import pytest + +# `pty` is POSIX-only and is imported at module scope, so on Windows this +# file fails at COLLECTION -- a hard ERROR, not a skip. The pytestmark +# below is evaluated only AFTER the module body has already executed, so +# it cannot prevent the import from raising. An ImportError during +# collection is indistinguishable in CI output from a real breakage. +if sys.platform == "win32": + pytest.skip( + "POSIX-only: requires pty, which has no Windows equivalent", + allow_module_level=True, + ) + import asyncio import contextlib import os From e02c76b8bff262592c2d088a6037d6b86ee4cccf Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:21:41 -0700 Subject: [PATCH 05/12] fix: actionable error message when bash is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With no bash on PATH, simple commands failed with a bare OS error naming neither cause nor fix: [WinError 2] The system cannot find the file specified. Root cause: The no-bash branch returned the actionable error only when the command contained a shell metacharacter. Otherwise it fell through to shlex.split() + create_subprocess_exec with no shell at all, so every cmd.exe builtin failed silently with WinError 2. A docstring also claimed the tool 'falls back to cmd.exe' — it never touched cmd.exe. Commands like 'echo hello' and 'ls' had no signal why they failed. A tool named 'bash' whose entire contract is POSIX shell semantics should not silently run a subset of commands through something else — that is a degraded state pretending to work, and the user's mental model breaks the first time quoting or a builtin differs, with no signal why. Fixed by making the actionable error unconditional when bash is missing, correcting the docstring to match, and adding regression tests that verify the error fires for both shell-free and shell-requiring commands. Linux: 62 → 65 passed (+3), 10 skipped. Ruff clean A/B. Regression test shown failing before the fix (old code reaches create_subprocess_exec for a plain command) and passing after. Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- amplifier_module_tool_bash/__init__.py | 74 ++++++----- .../test_gap_bash_missing_actionable_error.py | 121 ++++++++++++++++++ 2 files changed, 161 insertions(+), 34 deletions(-) create mode 100644 tests/test_gap_bash_missing_actionable_error.py diff --git a/amplifier_module_tool_bash/__init__.py b/amplifier_module_tool_bash/__init__.py index 4997b96..5d0bd19 100644 --- a/amplifier_module_tool_bash/__init__.py +++ b/amplifier_module_tool_bash/__init__.py @@ -1033,7 +1033,10 @@ async def _run_command( On Unix-like systems (Linux, macOS, WSL), uses bash for full shell features. On Windows, attempts to find bash (Git Bash or WSL bash). - Falls back to cmd.exe with limitations if bash is not found. + If bash is not found, every command fails with an actionable error + naming the cause and how to install bash (Git for Windows or WSL) -- + this tool's contract is POSIX shell semantics, so there is no + partial/degraded fallback (e.g. cmd.exe) for "simple" commands. Uses process groups for proper cleanup on timeout - kills entire process tree. """ @@ -1091,39 +1094,42 @@ async def _run_command( _assign_to_windows_job(process.pid) # GAP-024, see above _spawn_descendant_sweep(process.pid) else: - # No bash found - fall back to limited cmd.exe behavior - # Check for shell features that won't work in cmd.exe - shell_features = ["|", "&&", "||", "~", ">", "<", "2>&1", "$(", "`"] - if any(feature in command for feature in shell_features): - return { - "stdout": "", - "stderr": ( - "Bash not found in PATH.\n" - "\n" - "Shell features like |, &&, ||, ~, redirects require bash.\n" - "\n" - "Install Git for Windows (includes Git Bash):\n" - " https://git-scm.com/download/win\n" - "\n" - "Or install WSL:\n" - " https://learn.microsoft.com/en-us/windows/wsl/install" - ), - "returncode": 1, - } - - # Windows: Use direct execution (no shell) for simple commands - try: - args = shlex.split(command) - except ValueError as e: - raise ValueError(f"Invalid command syntax: {e}") - - process = await asyncio.create_subprocess_exec( - *args, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=self.working_dir, - ) - _assign_to_windows_job(process.pid) # GAP-024, see helper docstring + # No bash found on Windows. This tool's entire contract is + # POSIX shell semantics (quoting, tilde expansion, &&/||/|, + # redirects, command substitution) -- there is no cmd.exe + # fallback, and there never should be a *partial* one. + # Previously, only commands containing an obvious shell + # metacharacter got this actionable error; anything else + # (`echo hello`, `ls`, `dir`, cmd.exe builtins like `cd`, + # `type`, `set`, `copy`, ...) fell through to + # shlex.split() + exec-with-no-shell-at-all and failed with + # a bare `[WinError 2] The system cannot find the file + # specified` -- naming neither the cause nor the fix. A + # tool named `bash` silently running some commands with no + # shell (or, worse, through cmd.exe) is a degraded state + # pretending to be a working one: the user's mental model + # breaks the moment quoting or a builtin behaves + # differently, with no signal why. Fail loud, unconditionally, + # for every command, with the real cause and the fix. + return { + "stdout": "", + "stderr": ( + "Bash not found in PATH.\n" + "\n" + "This tool requires bash for POSIX shell semantics " + "(quoting, tilde expansion, pipes, redirects, " + "command substitution). Without it, even simple " + "commands cannot be run with correct, predictable " + "behavior.\n" + "\n" + "Install Git for Windows (includes Git Bash):\n" + " https://git-scm.com/download/win\n" + "\n" + "Or install WSL:\n" + " https://learn.microsoft.com/en-us/windows/wsl/install" + ), + "returncode": 1, + } else: # Unix-like (Linux, macOS, WSL): Use real bash shell # This enables: diff --git a/tests/test_gap_bash_missing_actionable_error.py b/tests/test_gap_bash_missing_actionable_error.py new file mode 100644 index 0000000..cab80b9 --- /dev/null +++ b/tests/test_gap_bash_missing_actionable_error.py @@ -0,0 +1,121 @@ +"""Regression test: bash-not-found on Windows must give an actionable error +for EVERY command, not a bare OS error for "simple" ones. + +## Why this test exists + +Confirmed on Windows with no bash on PATH (patching ``shutil.which`` to +return ``None`` and driving the real installed ``BashTool.execute()``): + +| Command | Result | +|------------------------------|--------------------------------------------| +| ``ls -la \\| head -3`` (shell) | actionable error naming bash + install URLs | +| ``echo hello`` | bare ``[WinError 2] ...`` | +| ``ls`` | bare ``[WinError 2] ...`` | +| ``dir`` | bare ``[WinError 2] ...`` | + +Root cause: the no-bash-on-Windows branch only returned the actionable +error when the command contained an obvious shell metacharacter (``|``, +``&&``, etc). Anything else fell through to ``shlex.split(command)`` + +``create_subprocess_exec`` with **no shell at all**, so every cmd.exe +builtin (echo, dir, cd, type, set, copy) failed with a bare OS error naming +neither the cause nor the fix. + +The fix makes the actionable error unconditional: if bash is not found on +Windows, EVERY command gets the actionable message. This tool's entire +contract is POSIX shell semantics; there is no partial/degraded fallback. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest +from amplifier_module_tool_bash import BashTool + + +@pytest.mark.asyncio +async def test_simple_command_gets_actionable_error_when_bash_missing() -> None: + """`echo hello` (no shell metacharacters) must still get the actionable + "bash not found" error when bash is missing on Windows -- not a bare + OS error from trying to exec a non-existent program with no shell. + """ + tool = BashTool({}) + + # A spy on create_subprocess_exec: if this is ever called, the fix + # regressed back to the no-shell fallback path this test exists to + # forbid. + exec_spy = AsyncMock() + + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch("amplifier_module_tool_bash.shutil.which", return_value=None), + patch("amplifier_module_tool_bash.asyncio.create_subprocess_exec", exec_spy), + ): + result = await tool._run_command("echo hello", timeout=5) + + assert not exec_spy.called, ( + "no-bash-on-Windows fell through to create_subprocess_exec (no " + "shell at all) for a command with no shell metacharacters -- this " + "is the exact regression the fix removes: a plain command like " + "`echo hello` must never be executed with no shell, it must get " + "the actionable bash-missing error like every other command" + ) + assert result["returncode"] != 0 + assert "WinError" not in result["stderr"], ( + f"got a bare OS error instead of the actionable message: {result['stderr']!r}" + ) + assert "bash" in result["stderr"].lower() + assert ( + "git-scm.com" in result["stderr"] + or "git for windows" in result["stderr"].lower() + ) + assert "wsl" in result["stderr"].lower() + + +@pytest.mark.asyncio +async def test_shell_metacharacter_command_still_gets_actionable_error() -> None: + """The pre-existing good case (a command with shell features) must keep + working -- this fix removes the *conditional*, it doesn't touch the + message itself. + """ + tool = BashTool({}) + exec_spy = AsyncMock() + + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch("amplifier_module_tool_bash.shutil.which", return_value=None), + patch("amplifier_module_tool_bash.asyncio.create_subprocess_exec", exec_spy), + ): + result = await tool._run_command("ls -la | head -3", timeout=5) + + assert not exec_spy.called + assert result["returncode"] != 0 + assert "bash" in result["stderr"].lower() + + +@pytest.mark.asyncio +async def test_posix_is_unaffected_when_bash_is_missing() -> None: + """This fix only changes the Windows no-bash branch. On POSIX, bash + missing is a wholly different (and pre-existing, out of scope) code + path -- verify it isn't touched by asserting the Windows-only spy + (create_subprocess_exec) is never reached via that branch when the + platform is POSIX. + """ + tool = BashTool({}) + exec_spy = AsyncMock() + + with ( + patch("amplifier_module_tool_bash.sys.platform", "linux"), + patch("amplifier_module_tool_bash.shutil.which", return_value=None), + patch("amplifier_module_tool_bash.asyncio.create_subprocess_exec", exec_spy), + patch( + "amplifier_module_tool_bash.asyncio.create_subprocess_shell", + AsyncMock(side_effect=RuntimeError("posix path reached shell exec")), + ),pytest.raises(RuntimeError, match="posix path reached shell exec") + ): + await tool._run_command("echo hello", timeout=5) + + assert not exec_spy.called, ( + "the Windows no-shell fallback (create_subprocess_exec) ran on " + "POSIX -- the platform guard leaks" + ) From 1bb358f5286a6c881eb47b611d0cd958ecc073cb Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:51:10 -0700 Subject: [PATCH 06/12] fix: make Git Bash discoverable and resolution transparent on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related Windows shell-resolution changes: 1. Git Bash was undiscoverable even when installed. On Windows with WSL enabled, shutil.which('bash') always returns System32 launcher first. Git for Windows' default install puts Git\bin on no PATH. Fixed by probing well-known install locations directly when PATH resolves nothing or user opts in via config/env. Preserves WSL default when both present; default unchanged. 2. Resolved shell was invisible until failure. Now logged at INFO and named in tool description with its path conventions (/mnt/c/... vs /c/...). Field research shows silent shell divergence is the dominant failure mode across Claude Code, Cursor, Codex CLI, and others. 3. Latent disagreement between foreground and background shell selection fixed by awaiting single cached-once-per-instance async resolution rather than defaulting on cache miss. Evidence: End-to-end on Windows 11 build 26200 with Git Bash 2.55.0.3: - auto mode resolves to WSL (/mnt/c/ paths) - AMPLIFIER_BASH_WINDOWS_SHELL=gitbash overrides to Git Bash (/c/ paths) - Unit coverage: 62 → 88 passed (+26 new tests covering all three changes) - Linux: ruff A/B identical (zero new issues) 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- README.md | 13 +- amplifier_module_tool_bash/__init__.py | 311 ++++++++++++++- tests/test_windows_shell_resolution.py | 513 +++++++++++++++++++++++++ 3 files changed, 822 insertions(+), 15 deletions(-) create mode 100644 tests/test_windows_shell_resolution.py diff --git a/README.md b/README.md index 1c56abe..0ea5ff0 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,15 @@ Execute a bash command with platform-appropriate shell. - ✅ Heredocs: `cat < None: ) +# --- Windows shell resolution: Git Bash discoverability + observability -- +# +# Root cause (confirmed on a real Windows 11 box, Git for Windows 2.55.0.3 +# installed a month prior): Git for Windows' *default* install puts +# `Git\cmd` on PATH (git.exe lives there) but NOT `Git\bin` (bash.exe lives +# there). Meanwhile `C:\Windows\System32\bash.exe` -- the WSL launcher +# stub -- is effectively always on PATH. The result: `shutil.which("bash")` +# resolves the WSL launcher every time, and Git Bash is unreachable no +# matter what's installed -- even though a `bash` call against a WSL box +# reaches a different filesystem, HOME, and toolchain (e.g. the WSL Linux +# Python, not the Windows Python the user actually has installed) than a +# `bash` call against Git Bash. +# +# Fix: (1) probe the well-known Git-for-Windows install locations directly +# on the filesystem, independent of PATH, so Git Bash becomes genuinely +# discoverable; (2) make the choice between WSL and Git Bash explicit and +# overridable via `windows_shell` config / the AMPLIFIER_BASH_WINDOWS_SHELL +# env var, defaulting to "auto" -- which preserves today's real-world +# default behavior exactly (PATH-first resolution, so WSL wins when both +# are present, unchanged for existing users) and only engages the new +# filesystem probing as a fallback when PATH resolves nothing at all +# (previously a hard "bash not found" error even with Git Bash installed). + +_WINDOWS_SHELL_PREFERENCE_ENV_VAR = "AMPLIFIER_BASH_WINDOWS_SHELL" +_VALID_WINDOWS_SHELL_PREFERENCES = ("auto", "wsl", "gitbash") + + +def _find_git_bash_executable() -> str | None: + """Probe well-known Git-for-Windows install locations for bash.exe, + independent of PATH (see module-level note above for why PATH alone + can never find it on a box where WSL is also installed). + + Returns the first that exists on disk, or None. + """ + candidates: list[str] = [] + local_app_data = os.environ.get("LOCALAPPDATA") + for env_var in ("ProgramFiles", "ProgramFiles(x86)"): + base = os.environ.get(env_var) + if base: + candidates.append(os.path.join(base, "Git", "bin", "bash.exe")) + if local_app_data: + candidates.append( + os.path.join(local_app_data, "Programs", "Git", "bin", "bash.exe") + ) + # bash.exe under Git\bin is normally a copy of the one under + # Git\usr\bin; some installs (or a damaged/partial one) may only have + # the latter, so check it too before giving up. + for env_var in ("ProgramFiles", "ProgramFiles(x86)"): + base = os.environ.get(env_var) + if base: + candidates.append(os.path.join(base, "Git", "usr", "bin", "bash.exe")) + if local_app_data: + candidates.append( + os.path.join(local_app_data, "Programs", "Git", "usr", "bin", "bash.exe") + ) + + for candidate in candidates: + if os.path.isfile(candidate): + return candidate + return None + + +def _find_wsl_bash_executable() -> str | None: + """Probe the well-known WSL launcher location directly, independent of + PATH. In practice PATH always resolves this one (System32 is on every + Windows PATH by construction) -- this exists mainly for symmetry with + `_find_git_bash_executable` and to serve explicit `windows_shell="wsl"` + requests robustly even in an unusual PATH configuration. + """ + system_root = os.environ.get("SystemRoot", r"C:\Windows") + candidate = os.path.join(system_root, "System32", "bash.exe") + return candidate if os.path.isfile(candidate) else None + + +def _looks_like_wsl_launcher_path(path: str | None) -> bool: + """Cheap, SYNCHRONOUS classification used only to build the tool + description / startup log line at construction time (see + `BashTool._windows_shell_startup_note`) -- NOT used to decide how a + command actually executes. That decision always goes through + `BashTool._is_wsl_bash`'s authoritative `test -d /mnt/wsl` subprocess + check. On a real Windows install, the WSL launcher only ever lives at + `%SystemRoot%\\System32\\bash.exe`, so a path-string check is a safe, + deterministic stand-in for the one place we can't afford to spawn a + process (a synchronous constructor). + """ + return path is not None and "system32" in path.lower() + + +def _arbitrate_windows_shell( + preference: str, + path_bash: str | None, + path_bash_is_wsl: bool, + git_bash_candidate: str | None, + wsl_bash_candidate: str | None, +) -> tuple[str | None, bool]: + """Pure decision: given what PATH resolves and what's discoverable via + the well-known install-location probes, decide which bash executable + wins and whether it's WSL bash. + + Shared by the authoritative async resolution + (`BashTool._resolve_windows_bash`, using a real subprocess check for + `path_bash_is_wsl`) and the synchronous, approximate one used for the + startup log/description (using the path heuristic above) -- so the + *decision* logic lives in exactly one place, even though *how + WSL-ness is determined* legitimately differs between the two callers. + + "auto" (default) preserves today's real-world behavior exactly: if + PATH resolves anything, it wins outright, full stop -- unchanged for + every existing user. Only when PATH resolves NOTHING does auto fall + back to the install-location probes (a strict improvement: previously + a hard error even with Git Bash installed). Explicit "wsl"/"gitbash" + preferences consider both PATH and the probes, so a user can force + Git Bash even where PATH resolves WSL's launcher first (the reported + bug) -- or force WSL even where PATH would resolve Git Bash first. + """ + if preference == "auto" and path_bash: + return path_bash, path_bash_is_wsl + + gitbash_exe = ( + path_bash if (path_bash and not path_bash_is_wsl) else git_bash_candidate + ) + wsl_exe = path_bash if (path_bash and path_bash_is_wsl) else wsl_bash_candidate + + if preference == "gitbash" and gitbash_exe: + return gitbash_exe, False + if preference == "wsl" and wsl_exe: + return wsl_exe, True + + if preference != "auto": + logger.warning( + "tool-bash: windows_shell=%r requested but not available on " + "this machine; falling back to auto-detection", + preference, + ) + + if wsl_exe: + return wsl_exe, True + if gitbash_exe: + return gitbash_exe, False + return None, False + + async def mount(coordinator: ModuleCoordinator, config: dict[str, Any] | None = None): """ Mount the bash tool. @@ -529,6 +671,11 @@ async def mount(coordinator: ModuleCoordinator, config: dict[str, Any] | None = - allowed_commands: Whitelist of allowed commands (default: []) - denied_commands: Additional custom blocklist patterns (default: []) - safety_overrides: Fine-grained safety overrides dict with 'allow' and 'block' lists + - windows_shell: Windows-only. Which bash to prefer: "auto" (default, + PATH-first -- unchanged from prior behavior), "wsl", or "gitbash". + Also settable via the AMPLIFIER_BASH_WINDOWS_SHELL env var + (config takes precedence). See _arbitrate_windows_shell for + the full resolution/fallback rules. Returns: Optional cleanup function @@ -634,6 +781,110 @@ def __init__(self, config: dict[str, Any]): # Cache for WSL bash detection to avoid repeated checks self._wsl_bash_cache: dict[str, bool] = {} + # Windows shell resolution: which bash (WSL vs Git Bash) to use, and + # whether the choice has been explicitly overridden. See the + # module-level note above `_arbitrate_windows_shell` for why PATH + # alone can't be trusted to ever surface Git Bash. + self._windows_shell_preference = self._resolve_windows_shell_preference(config) + # Cache for the AUTHORITATIVE resolution (async, subprocess-verified + # is_wsl check) used to actually execute commands. Resolved once per + # instance, not per command -- shared by both the foreground + # (_run_command) and background (_run_command_background) paths so + # they can never disagree (see _resolve_windows_bash docstring). + self._windows_bash_resolved: tuple[str | None, bool] | None = None + + # Windows only: append a startup note (log + tool description) naming + # the shell we expect to resolve to, so a user/model isn't left to + # discover it only after a confusing failure hundreds of calls in + # (e.g. `python script.py` failing because WSL bash can't see the + # Windows Python). Uses a synchronous, approximate classification + # (`_looks_like_wsl_launcher_path`) since the authoritative, + # subprocess-verified check can't run inside a sync constructor -- + # the real execution routing is unaffected and always uses that + # authoritative check via `_resolve_windows_bash`. + if sys.platform == "win32": + self.description = self.description + self._windows_shell_startup_note() + + @staticmethod + def _resolve_windows_shell_preference(config: dict[str, Any]) -> str: + """Resolve the explicit Windows shell preference: `windows_shell` + config key, then the AMPLIFIER_BASH_WINDOWS_SHELL env var, then + "auto" (today's default: PATH-first, unchanged). + + This module has no existing config-resolution system to plug into + (checked: no env vars, no config layer beyond plain config.get(...) + calls) -- a plain env var + config key, in the spirit of the + existing code, is the whole mechanism. + """ + value = config.get("windows_shell") or os.environ.get( + _WINDOWS_SHELL_PREFERENCE_ENV_VAR + ) + if not value: + return "auto" + value = value.strip().lower() + if value not in _VALID_WINDOWS_SHELL_PREFERENCES: + logger.warning( + "tool-bash: unknown windows_shell=%r (expected one of %s); " + "using 'auto'", + value, + _VALID_WINDOWS_SHELL_PREFERENCES, + ) + return "auto" + return value + + def _windows_shell_startup_note(self) -> str: + """Build the Windows-only description/log note naming the shell we + expect to resolve to and its path conventions -- the model needs + this BEFORE its first command (WSL mounts Windows drives at + /mnt/c/..., Git Bash at /c/...; guessing wrong -- not a hard + error -- was found to be the dominant failure mode across + comparable CLI agents). Approximate (see + `_looks_like_wsl_launcher_path`); the actual command routing + always uses the authoritative, subprocess-verified check in + `_resolve_windows_bash`. + """ + path_bash = shutil.which("bash") + path_bash_is_wsl = _looks_like_wsl_launcher_path(path_bash) + exe, is_wsl = _arbitrate_windows_shell( + self._windows_shell_preference, + path_bash, + path_bash_is_wsl, + _find_git_bash_executable(), + _find_wsl_bash_executable(), + ) + + logger.info( + "tool-bash: Windows shell (approx; confirmed on first command) -> %s (%s)", + exe or "NOT FOUND", + "wsl" if is_wsl else ("gitbash" if exe else "none"), + ) + + override_hint = ( + "\n(Override: set windows_shell config or " + f"{_WINDOWS_SHELL_PREFERENCE_ENV_VAR} env var to 'wsl' or " + "'gitbash'.)" + ) + if exe is None: + return ( + "\n\nWINDOWS SHELL: no bash found (WSL or Git Bash). Every " + "command will fail with an actionable error naming how to " + "install one." + ) + if is_wsl: + return ( + f"\n\nWINDOWS SHELL: WSL bash ({exe}). Windows drives are " + "mounted at /mnt/c/..., not /c/...; $HOME is the WSL " + "Linux home, not the Windows user profile; the toolchain " + "(e.g. python) is whatever is installed INSIDE that Linux " + "distro, not on Windows." + override_hint + ) + return ( + f"\n\nWINDOWS SHELL: Git Bash ({exe}). Windows drives are " + "mounted at /c/..., not /mnt/c/...; $HOME is the Windows user " + "profile; the toolchain (e.g. python) is whatever is " + "installed on Windows itself." + override_hint + ) + @property def input_schema(self) -> dict: """Return JSON schema for tool parameters.""" @@ -947,6 +1198,45 @@ async def _is_wsl_bash(self, bash_exe: str) -> bool: self._wsl_bash_cache[bash_exe] = False return False + async def _resolve_windows_bash(self) -> tuple[str | None, bool]: + """Authoritative Windows shell resolution: which bash executable to + use, and whether it's WSL bash. Resolved ONCE per instance and + cached -- both `_run_command` and `_run_command_background` call + this (instead of each rolling their own PATH lookup / cache read) + so the two paths can never disagree about which shell is active. + + Previously `_run_command_background` read + `self._wsl_bash_cache.get(bash_exe, False)` directly, defaulting to + False -- so a background command issued before any foreground + command routed WSL's bash.exe down the Git-Bash direct-exec + branch, bypassing the `wsl --exec` wrapper that exists + specifically to prevent premature variable expansion. Since this + method is itself async, both call sites can simply await it + instead. + """ + if self._windows_bash_resolved is not None: + return self._windows_bash_resolved + + path_bash = shutil.which("bash") + path_bash_is_wsl = await self._is_wsl_bash(path_bash) if path_bash else False + + resolved = _arbitrate_windows_shell( + self._windows_shell_preference, + path_bash, + path_bash_is_wsl, + _find_git_bash_executable(), + _find_wsl_bash_executable(), + ) + self._windows_bash_resolved = resolved + + exe, is_wsl = resolved + logger.info( + "tool-bash: Windows shell resolved -> %s (%s)", + exe or "NOT FOUND", + "wsl" if is_wsl else ("gitbash" if exe else "none"), + ) + return resolved + async def _run_command_background(self, command: str) -> dict[str, Any]: """Run command in background, returning immediately with PID. @@ -966,14 +1256,14 @@ async def _run_command_background(self, command: str) -> dict[str, Any]: devnull = subprocess.DEVNULL if is_windows: - # Windows background execution - bash_exe = shutil.which("bash") + # Windows background execution. Resolution is authoritative and + # shared with the foreground path via `_resolve_windows_bash` + # (this method is itself async, so it can simply await it) -- + # see that method's docstring for why the previous + # `self._wsl_bash_cache.get(bash_exe, False)` read here could + # silently disagree with the foreground path. + bash_exe, is_wsl = await self._resolve_windows_bash() if bash_exe: - # Determine if WSL bash (requires special invocation) - # Note: We need to run detection synchronously here since Popen is sync - # Use cached result if available, otherwise assume not WSL for background - is_wsl = self._wsl_bash_cache.get(bash_exe, False) - if is_wsl: # WSL bash: Use 'wsl --exec bash -c' to prevent premature variable expansion process = subprocess.Popen( @@ -1046,15 +1336,14 @@ async def _run_command( pgid = None if is_windows: - # Try to find bash (Git Bash or WSL bash) - bash_exe = shutil.which("bash") + # Resolve which bash to use (Git Bash or WSL bash) -- shared, + # cached-once resolution; see `_resolve_windows_bash` docstring. + bash_exe, is_wsl = await self._resolve_windows_bash() if bash_exe: # Bash found on Windows - use create_subprocess_exec to handle # paths with spaces (e.g., "C:\Program Files\Git\bin\bash.exe") # and properly handle WSL bash variable expansion - is_wsl = await self._is_wsl_bash(bash_exe) - if is_wsl: # WSL bash: Use 'wsl --exec bash -c' to prevent premature # variable expansion by the WSL launcher diff --git a/tests/test_windows_shell_resolution.py b/tests/test_windows_shell_resolution.py new file mode 100644 index 0000000..975f367 --- /dev/null +++ b/tests/test_windows_shell_resolution.py @@ -0,0 +1,513 @@ +"""Regression tests: Windows shell resolution (Git Bash discoverability, +resolved-shell observability, foreground/background agreement). + +## Why these tests exist + +Confirmed on a real Windows 11 box with Git for Windows 2.55.0.3 installed +a month prior to testing: + + shutil.which('bash') -> 'C:\\Windows\\system32\\bash.EXE' # WSL launcher + + persisted registry PATH: + MACHINE: ... C:\\Windows\\system32 (position 4) ... + C:\\Program Files\\Git\\cmd (position ~24) + USER: (no Git entries) + +Root cause: Git for Windows' *default* install puts `Git\\cmd` on PATH +(git.exe lives there) but NOT `Git\\bin` (bash.exe lives there). Meanwhile +the WSL launcher stub at `C:\\Windows\\System32\\bash.exe` is effectively +always on PATH. So `shutil.which("bash")` resolves the WSL launcher every +time, and Git Bash is unreachable no matter what's installed -- even +though a `bash` call against a WSL box reaches a different filesystem, +$HOME, and toolchain than a `bash` call against Git Bash: + + WSL bash Git Bash + git --version git version 2.43.0 git version 2.55.0.windows.3 + which python NO_PYTHON /c/Python314/python + uname -s Linux MINGW64_NT-10.0-26200 + $HOME /home/brkrabac /c/Users/brkrabac + +These tests run on any platform (they patch `sys.platform`, `shutil.which`, +`os.environ`, `os.path.isfile`, and the subprocess-spawning calls) and +assert the resolution OUTCOME, not the real OS state. +""" + +from __future__ import annotations + +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import amplifier_module_tool_bash as mod +import pytest +from amplifier_module_tool_bash import ( + BashTool, + _arbitrate_windows_shell, + _find_git_bash_executable, + _find_wsl_bash_executable, +) + +# Built with os.path.join (not hardcoded backslash strings) so these match +# exactly what the production code computes regardless of which platform +# the test SUITE happens to run on -- production always runs on real +# Windows (backslash-joining `ntpath`), but these tests run on Linux/macOS +# CI too, where `os.path.join` joins with "/" instead. Using the same +# join call here as the code under test keeps the comparison meaningful +# without asserting anything about actual separator characters. +PROGRAM_FILES = r"C:\Program Files" +SYSTEM_ROOT = r"C:\Windows" +WSL_LAUNCHER = os.path.join(SYSTEM_ROOT, "System32", "bash.exe") +GIT_BASH = os.path.join(PROGRAM_FILES, "Git", "bin", "bash.exe") + + +class _FakeProc: + """Minimal stand-in for an asyncio subprocess, returncode configurable.""" + + def __init__(self, pid: int = 999, returncode: int = 0) -> None: + self.pid = pid + self.returncode = returncode + + async def communicate(self, *_args, **_kwargs) -> tuple[bytes, bytes]: + return (b"", b"") + + +def _is_wsl_subprocess_mock(returncode: int) -> AsyncMock: + """Build an AsyncMock standing in for asyncio.create_subprocess_exec, + used by `_is_wsl_bash`'s `test -d /mnt/wsl` check. returncode=0 means + "is WSL", nonzero means "is not WSL". + """ + return AsyncMock(return_value=_FakeProc(returncode=returncode)) + + +# --------------------------------------------------------------------------- +# 1. Pure arbitration function -- fast, no async/platform patching needed. +# --------------------------------------------------------------------------- + + +class TestArbitrateWindowsShell: + """Unit tests for the shared, pure decision function.""" + + def test_auto_uses_path_result_outright_when_present(self): + """This is the crux of "don't change the default for existing + users": in auto mode, if PATH resolves ANYTHING, it wins -- + exactly today's behavior -- regardless of what else is + discoverable via the well-known install-location probes. + """ + exe, is_wsl = _arbitrate_windows_shell( + "auto", + path_bash=WSL_LAUNCHER, + path_bash_is_wsl=True, + git_bash_candidate=GIT_BASH, # discoverable, but must NOT win + wsl_bash_candidate=WSL_LAUNCHER, + ) + assert exe == WSL_LAUNCHER + assert is_wsl is True + + def test_auto_falls_back_to_gitbash_when_path_resolves_nothing(self): + """Strict improvement: previously a hard 'bash not found' error + even when Git Bash was actually installed, because PATH alone + never sees it (Git\\bin is not on PATH by default). + """ + exe, is_wsl = _arbitrate_windows_shell( + "auto", + path_bash=None, + path_bash_is_wsl=False, + git_bash_candidate=GIT_BASH, + wsl_bash_candidate=None, + ) + assert exe == GIT_BASH + assert is_wsl is False + + def test_auto_with_nothing_discoverable_returns_none(self): + exe, is_wsl = _arbitrate_windows_shell( + "auto", + path_bash=None, + path_bash_is_wsl=False, + git_bash_candidate=None, + wsl_bash_candidate=None, + ) + assert exe is None + assert is_wsl is False + + def test_explicit_gitbash_preference_wins_even_when_path_resolves_wsl(self): + """The reported bug, fixed: a user can force Git Bash even though + PATH resolves the WSL launcher first. + """ + exe, is_wsl = _arbitrate_windows_shell( + "gitbash", + path_bash=WSL_LAUNCHER, + path_bash_is_wsl=True, + git_bash_candidate=GIT_BASH, + wsl_bash_candidate=WSL_LAUNCHER, + ) + assert exe == GIT_BASH + assert is_wsl is False + + def test_explicit_wsl_preference_wins_even_when_path_resolves_gitbash(self): + exe, is_wsl = _arbitrate_windows_shell( + "wsl", + path_bash=GIT_BASH, + path_bash_is_wsl=False, + git_bash_candidate=GIT_BASH, + wsl_bash_candidate=WSL_LAUNCHER, + ) + assert exe == WSL_LAUNCHER + assert is_wsl is True + + def test_explicit_preference_falls_back_with_warning_when_unavailable(self): + """Asking for gitbash when none is discoverable anywhere falls back + to auto-detection (still gives the user a working shell) rather + than a hard failure -- but must warn, since the request was + explicit and silently ignoring it would be confusing. + """ + with patch.object(mod, "logger") as log: + exe, is_wsl = _arbitrate_windows_shell( + "gitbash", + path_bash=WSL_LAUNCHER, + path_bash_is_wsl=True, + git_bash_candidate=None, + wsl_bash_candidate=WSL_LAUNCHER, + ) + assert exe == WSL_LAUNCHER + assert is_wsl is True + assert log.warning.called + + +# --------------------------------------------------------------------------- +# 2. Filesystem probing helpers +# --------------------------------------------------------------------------- + + +class TestFilesystemProbes: + def test_find_git_bash_executable_probes_known_locations(self): + with ( + patch.dict( + mod.os.environ, + {"ProgramFiles": r"C:\Program Files"}, + clear=True, + ), + patch( + "amplifier_module_tool_bash.os.path.isfile", + side_effect=lambda p: p == GIT_BASH, + ), + ): + assert _find_git_bash_executable() == GIT_BASH + + def test_find_git_bash_executable_returns_none_when_not_installed(self): + with ( + patch.dict(mod.os.environ, {}, clear=True), + patch("amplifier_module_tool_bash.os.path.isfile", return_value=False), + ): + assert _find_git_bash_executable() is None + + def test_find_wsl_bash_executable_probes_system_root(self): + with ( + patch.dict(mod.os.environ, {"SystemRoot": r"C:\Windows"}, clear=True), + patch( + "amplifier_module_tool_bash.os.path.isfile", + side_effect=lambda p: p == WSL_LAUNCHER, + ), + ): + assert _find_wsl_bash_executable() == WSL_LAUNCHER + + +# --------------------------------------------------------------------------- +# 3. BashTool._resolve_windows_bash -- the authoritative, cached resolution +# used to actually execute commands. +# --------------------------------------------------------------------------- + + +class TestResolveWindowsBash: + @pytest.mark.asyncio + async def test_gitbash_selectable_via_config_when_wsl_on_path(self): + """End-to-end version of the reported bug: PATH resolves the WSL + launcher (as it always does when WSL is installed), but the user + has asked (via config) for Git Bash, and Git Bash IS installed at + a well-known location -- it must be discoverable and chosen. + """ + tool = BashTool({"windows_shell": "gitbash"}) + + with ( + patch("amplifier_module_tool_bash.shutil.which", return_value=WSL_LAUNCHER), + patch( + "amplifier_module_tool_bash.asyncio.create_subprocess_exec", + _is_wsl_subprocess_mock(returncode=0), # confirms WSL_LAUNCHER is WSL + ), + patch.dict( + mod.os.environ, {"ProgramFiles": r"C:\Program Files"}, clear=True + ), + patch( + "amplifier_module_tool_bash.os.path.isfile", + side_effect=lambda p: p == GIT_BASH, + ), + ): + exe, is_wsl = await tool._resolve_windows_bash() + + assert exe == GIT_BASH, ( + "requested windows_shell='gitbash' but resolution still picked " + f"{exe!r} -- Git Bash was not made reachable" + ) + assert is_wsl is False + + @pytest.mark.asyncio + async def test_auto_default_unchanged_when_both_present(self): + """Judgement call from the report: auto (default) keeps WSL when + both are installed -- must not silently change behavior for + existing users. + """ + tool = BashTool({}) # no explicit preference -> "auto" + + with ( + patch("amplifier_module_tool_bash.shutil.which", return_value=WSL_LAUNCHER), + patch( + "amplifier_module_tool_bash.asyncio.create_subprocess_exec", + _is_wsl_subprocess_mock(returncode=0), + ), + patch.dict( + mod.os.environ, {"ProgramFiles": r"C:\Program Files"}, clear=True + ), + patch( + "amplifier_module_tool_bash.os.path.isfile", + side_effect=lambda p: p == GIT_BASH, + ), + ): + exe, is_wsl = await tool._resolve_windows_bash() + + assert exe == WSL_LAUNCHER + assert is_wsl is True + + @pytest.mark.asyncio + async def test_auto_fallback_finds_gitbash_when_path_empty(self): + """Previously: shutil.which("bash") returning None meant an + unconditional 'bash not found' error, even with Git Bash actually + installed. Now: auto falls back to the well-known locations. + """ + tool = BashTool({}) + + with ( + patch("amplifier_module_tool_bash.shutil.which", return_value=None), + patch.dict( + mod.os.environ, {"ProgramFiles": r"C:\Program Files"}, clear=True + ), + patch( + "amplifier_module_tool_bash.os.path.isfile", + side_effect=lambda p: p == GIT_BASH, + ), + ): + exe, is_wsl = await tool._resolve_windows_bash() + + assert exe == GIT_BASH + assert is_wsl is False + + @pytest.mark.asyncio + async def test_resolution_is_cached_across_calls(self): + """Resolved once, not per command -- shutil.which must only be + consulted on the first call. + """ + tool = BashTool({}) + which_mock = MagicMock(return_value=WSL_LAUNCHER) + + with ( + patch("amplifier_module_tool_bash.shutil.which", which_mock), + patch( + "amplifier_module_tool_bash.asyncio.create_subprocess_exec", + _is_wsl_subprocess_mock(returncode=0), + ), + ): + first = await tool._resolve_windows_bash() + second = await tool._resolve_windows_bash() + + assert first == second + assert which_mock.call_count == 1, ( + "shutil.which was consulted more than once -- resolution is " + "supposed to be cached per instance, not re-done per command" + ) + + +# --------------------------------------------------------------------------- +# 4. Foreground/background agreement -- the latent cache-default bug. +# --------------------------------------------------------------------------- + + +class TestForegroundBackgroundAgree: + @pytest.mark.asyncio + async def test_background_first_call_still_routes_wsl_through_wrapper(self): + """The bug: `_run_command_background` used to read + `self._wsl_bash_cache.get(bash_exe, False)` directly, defaulting to + False. Issued as the very FIRST command (cold cache, nothing has + populated `_wsl_bash_cache` yet), a WSL bash.exe would be sent down + the Git-Bash direct-exec branch (`[bash_exe, "-c", command]`) + instead of `["wsl", "--exec", "bash", "-c", command]` -- bypassing + the wrapper that exists specifically to prevent the WSL launcher + from prematurely expanding shell variables. + + This must not happen: even as the first call, a WSL-classified + bash.exe must be launched via the wsl wrapper. + """ + tool = BashTool({}) + popen_mock = MagicMock(return_value=MagicMock(pid=4242)) + + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch("amplifier_module_tool_bash.shutil.which", return_value=WSL_LAUNCHER), + patch( + "amplifier_module_tool_bash.asyncio.create_subprocess_exec", + _is_wsl_subprocess_mock(returncode=0), # WSL_LAUNCHER IS wsl + ), + patch("amplifier_module_tool_bash.subprocess.Popen", popen_mock), + # Windows-only constants that don't exist on the real `subprocess` + # module on Linux/macOS -- the test suite runs there, so these + # must be supplied for the (mocked-anyway) Popen call's keyword + # arguments to even evaluate. + patch.object(mod.subprocess, "DETACHED_PROCESS", 0x00000008, create=True), + patch.object( + mod.subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200, create=True + ), + ): + # No foreground call happened first -- cache is genuinely cold. + assert tool._wsl_bash_cache == {} + result = await tool._run_command_background("echo hi") + + assert "pid" in result + assert popen_mock.called, "background execution never spawned a process" + spawned_args = popen_mock.call_args.args[0] + assert spawned_args == ["wsl", "--exec", "bash", "-c", "echo hi"], ( + f"background command used {spawned_args!r} on a WSL bash.exe -- " + "expected the ['wsl', '--exec', 'bash', '-c', ...] wrapper. This " + "is the exact foreground/background disagreement the fix removes." + ) + + @pytest.mark.asyncio + async def test_foreground_and_background_agree_on_same_resolution(self): + """Both paths must resolve to the identical (exe, is_wsl) via the + same shared, cached call -- shutil.which is consulted only once + total across both. + """ + tool = BashTool({}) + which_mock = MagicMock(return_value=WSL_LAUNCHER) + popen_mock = MagicMock(return_value=MagicMock(pid=4242)) + fg_proc = _FakeProc(pid=1234, returncode=0) + + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch("amplifier_module_tool_bash.shutil.which", which_mock), + patch( + "amplifier_module_tool_bash.asyncio.create_subprocess_exec", + AsyncMock(return_value=fg_proc), + ), + patch("amplifier_module_tool_bash.subprocess.Popen", popen_mock), + patch("amplifier_module_tool_bash._assign_to_windows_job", MagicMock()), + patch("amplifier_module_tool_bash._spawn_descendant_sweep", MagicMock()), + patch.object(mod.subprocess, "DETACHED_PROCESS", 0x00000008, create=True), + patch.object( + mod.subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200, create=True + ), + ): + await tool._run_command("echo fg", timeout=5) + await tool._run_command_background("echo bg") + + assert which_mock.call_count == 1, ( + "foreground and background each re-resolved the shell " + "independently instead of sharing one cached resolution" + ) + spawned_args = popen_mock.call_args.args[0] + assert spawned_args[0] == "wsl", ( + "background disagreed with foreground's WSL classification" + ) + + +# --------------------------------------------------------------------------- +# 5. Observability: description names the resolved shell. +# --------------------------------------------------------------------------- + + +class TestWindowsShellDescription: + def test_description_names_wsl_shell_and_path_convention(self): + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch("amplifier_module_tool_bash.shutil.which", return_value=WSL_LAUNCHER), + ): + tool = BashTool({}) + + assert "WINDOWS SHELL" in tool.description + assert WSL_LAUNCHER in tool.description + assert "/mnt/c/" in tool.description, ( + "model needs the WSL path convention up front, before its " + "first command, to avoid guessing wrong (the dominant " + "failure mode found across comparable CLI agents)" + ) + + def test_description_names_gitbash_shell_and_path_convention(self): + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch("amplifier_module_tool_bash.shutil.which", return_value=GIT_BASH), + ): + tool = BashTool({}) + + assert "WINDOWS SHELL" in tool.description + assert GIT_BASH in tool.description + assert "Git Bash" in tool.description + note = tool.description.split("WINDOWS SHELL")[-1] + assert "mounted at /c/" in note, ( + "model needs the Git Bash path convention up front, before " + "its first command" + ) + assert "WSL Linux home" not in note, ( + "Git Bash note incorrectly describes WSL's $HOME convention" + ) + + def test_description_names_missing_shell(self): + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch("amplifier_module_tool_bash.shutil.which", return_value=None), + patch.dict(mod.os.environ, {}, clear=True), + patch("amplifier_module_tool_bash.os.path.isfile", return_value=False), + ): + tool = BashTool({}) + + assert "no bash found" in tool.description.lower() + + def test_posix_description_is_byte_identical_to_base(self): + """POSIX must not gain the Windows note at all -- confirms the + Fix 2 change is confined to `sys.platform == "win32"`. + """ + with patch("amplifier_module_tool_bash.sys.platform", "linux"): + tool = BashTool({}) + + assert tool.description == BashTool.description + assert "WINDOWS SHELL" not in tool.description + + +# --------------------------------------------------------------------------- +# 6. Preference resolution (config vs env var vs default). +# --------------------------------------------------------------------------- + + +class TestWindowsShellPreferenceResolution: + def test_config_key_takes_precedence_over_env_var(self): + with patch.dict( + mod.os.environ, {"AMPLIFIER_BASH_WINDOWS_SHELL": "wsl"}, clear=False + ): + pref = BashTool._resolve_windows_shell_preference( + {"windows_shell": "gitbash"} + ) + assert pref == "gitbash" + + def test_env_var_used_when_no_config_key(self): + with patch.dict( + mod.os.environ, {"AMPLIFIER_BASH_WINDOWS_SHELL": "wsl"}, clear=False + ): + pref = BashTool._resolve_windows_shell_preference({}) + assert pref == "wsl" + + def test_defaults_to_auto(self): + with patch.dict(mod.os.environ, {}, clear=True): + pref = BashTool._resolve_windows_shell_preference({}) + assert pref == "auto" + + def test_unknown_value_warns_and_falls_back_to_auto(self): + with patch.object(mod, "logger") as log: + pref = BashTool._resolve_windows_shell_preference( + {"windows_shell": "powershell"} + ) + assert pref == "auto" + assert log.warning.called From 0555d6189e7b2c9e24183f5ef3a5f5471e5d17c4 Mon Sep 17 00:00:00 2001 From: sadlilas <11658960+sadlilas@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:13:28 -0700 Subject: [PATCH 07/12] fix: declare missing ctypes signatures on Win32 job/snapshot calls SetInformationJobObject had a restype but no argtypes, so the argument list still defaulted to C int, which is exactly the truncation this class of bug produces on 64-bit Windows. Declared HANDLE, c_int, LPVOID, DWORD to match the real SetInformationJobObject signature. _enumerate_child_pids_windows was worse: none of its four kernel32 calls (CreateToolhelp32Snapshot, Process32First, Process32Next, CloseHandle) had any signature declared at all. CreateToolhelp32Snapshot returns a HANDLE, so an undeclared restype truncates it to 32 bits on a 64-bit process -- silent corruption, not a clean failure. Declared all four explicitly using wintypes.HANDLE/DWORD/BOOL and POINTER(PROCESSENTRY32), same pattern already used for the job-object calls a few functions above. Added tests/test_windows_ctypes_signatures.py to actually exercise this. Patching sys.platform to "win32" and ctypes.WinDLL to a MagicMock lets _get_windows_job_object, _assign_to_windows_job, and _enumerate_child_pids_windows run for real -- the DLL handle is the only stand-in, the signature-declaration code executes as written -- and the test asserts every kernel32 function touched has both argtypes and restype set. Runs on Linux/macOS/CI today; no Windows box required. --- amplifier_module_tool_bash/__init__.py | 24 +++++ tests/test_windows_ctypes_signatures.py | 126 ++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 tests/test_windows_ctypes_signatures.py diff --git a/amplifier_module_tool_bash/__init__.py b/amplifier_module_tool_bash/__init__.py index e6f4fdd..1f6e313 100644 --- a/amplifier_module_tool_bash/__init__.py +++ b/amplifier_module_tool_bash/__init__.py @@ -221,6 +221,12 @@ def _get_windows_job_object(): kernel32.CreateJobObjectW.restype = wintypes.HANDLE kernel32.CreateJobObjectW.argtypes = [wintypes.LPVOID, wintypes.LPCWSTR] kernel32.SetInformationJobObject.restype = wintypes.BOOL + kernel32.SetInformationJobObject.argtypes = [ + wintypes.HANDLE, + ctypes.c_int, + wintypes.LPVOID, + wintypes.DWORD, + ] kernel32.CloseHandle.restype = wintypes.BOOL kernel32.CloseHandle.argtypes = [wintypes.HANDLE] @@ -404,6 +410,24 @@ class PROCESSENTRY32(ctypes.Structure): ] kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + # Same reasoning as the job-object signatures above: CreateToolhelp32Snapshot + # returns a HANDLE, and leaving restype undeclared truncates it to a 32-bit + # c_int on 64-bit Windows. Declare all four signatures explicitly. + kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE + kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD] + kernel32.Process32First.restype = wintypes.BOOL + kernel32.Process32First.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(PROCESSENTRY32), + ] + kernel32.Process32Next.restype = wintypes.BOOL + kernel32.Process32Next.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(PROCESSENTRY32), + ] + kernel32.CloseHandle.restype = wintypes.BOOL + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + snap = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) if snap in (-1, 0): return set() diff --git a/tests/test_windows_ctypes_signatures.py b/tests/test_windows_ctypes_signatures.py new file mode 100644 index 0000000..1b28e64 --- /dev/null +++ b/tests/test_windows_ctypes_signatures.py @@ -0,0 +1,126 @@ +"""Regression test: every Win32 ctypes call site declares argtypes/restype. + +## Why this test exists + +ctypes silently defaults an undeclared `restype`/`argtypes` to C `int` +(32-bit signed). On 64-bit Windows, a HANDLE or pointer-sized value routed +through that default gets truncated -- producing wrong results or memory +corruption rather than a clean error. This is exactly the kind of bug that +looks fine in review and only misbehaves on a real 64-bit Windows box. + +None of the existing suites exercise this class of bug on non-Windows CI: + +- `test_windows_job_call_sites.py` patches `_assign_to_windows_job` and + `_spawn_descendant_sweep` as whole functions, so the ctypes calls inside + them never run. +- `test_gap013_windows_job_object_orphans.py` is `skipif`'d to + `sys.platform == "win32"`, so it executes nowhere in this repo's CI. + +That leaves the signature declarations for `CreateJobObjectW`, +`SetInformationJobObject`, `OpenProcess`, `AssignProcessToJobObject`, +`CreateToolhelp32Snapshot`, `Process32First`, `Process32Next`, and +`CloseHandle` unverified anywhere except a real Windows machine. + +`ctypes.WinDLL` does not exist as a real attribute on non-Windows platforms +(hence `create=True` below), but `ctypes.wintypes` is plain, portable +`ctypes.Structure`/`c_*` aliasing that imports and behaves identically on +any platform. That means the three Windows-only functions under test can +run for real here -- only the DLL handle itself is a stand-in -- so this +test drives the genuine signature-declaration code, not a paraphrase of it, +and asserts that every Win32 function actually invoked had both `argtypes` +and `restype` explicitly assigned. It runs on Linux, macOS, and CI, today. +""" + +from __future__ import annotations + +import ctypes +from unittest.mock import MagicMock, patch + +import amplifier_module_tool_bash as mod + + +def _assert_signature_declared(kernel32_mock: MagicMock, name: str) -> None: + fn = getattr(kernel32_mock, name) + assert fn.argtypes is not None, ( + f"kernel32.{name} has no argtypes declared -- ctypes will default " + "arguments to C int, truncating a 64-bit pointer/HANDLE on 64-bit " + "Windows" + ) + assert fn.restype is not None, ( + f"kernel32.{name} has no restype declared -- ctypes defaults the " + "return value to C int, truncating a 64-bit HANDLE on 64-bit " + "Windows" + ) + + +class TestWin32CallSignatures: + """Drives the real Windows-only functions with a mocked kernel32 DLL + handle so the signature declarations are exercised on any platform. + """ + + def setup_method(self) -> None: + # Module-level cache; must not leak between tests or across the + # rest of the suite. + self._saved_job_handle = mod._windows_job_handle + mod._windows_job_handle = None + + def teardown_method(self) -> None: + mod._windows_job_handle = self._saved_job_handle + + def test_job_object_creation_declares_signatures(self) -> None: + kernel32 = MagicMock(name="kernel32") + windll_factory = MagicMock(return_value=kernel32) + + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch.object(ctypes, "WinDLL", windll_factory, create=True), + ): + job = mod._get_windows_job_object() + + assert job is not None, ( + "job creation must succeed against a mocked, all-truthy kernel32" + ) + for name in ("CreateJobObjectW", "SetInformationJobObject", "CloseHandle"): + _assert_signature_declared(kernel32, name) + + def test_assign_to_job_declares_signatures(self) -> None: + kernel32 = MagicMock(name="kernel32") + windll_factory = MagicMock(return_value=kernel32) + + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch.object(ctypes, "WinDLL", windll_factory, create=True), + ): + assigned = mod._assign_to_windows_job(4242) + + assert assigned is True, ( + "assignment must succeed against a mocked, all-truthy kernel32" + ) + for name in ("OpenProcess", "AssignProcessToJobObject", "CloseHandle"): + _assert_signature_declared(kernel32, name) + + def test_descendant_enumeration_declares_signatures(self) -> None: + kernel32 = MagicMock(name="kernel32") + # Falsy on the first call ends the process-table walk immediately + # (simulates an empty/failed walk) -- a real MagicMock is truthy + # forever, which would otherwise spin the `while True` loop in + # `_enumerate_child_pids_windows` forever via `Process32Next`. + kernel32.Process32First.return_value = False + windll_factory = MagicMock(return_value=kernel32) + + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch.object(ctypes, "WinDLL", windll_factory, create=True), + ): + children = mod._enumerate_child_pids_windows(1234) + + assert children == set() + # All four signatures are declared unconditionally before the walk + # begins, so this holds even though Process32Next is never reached. + for name in ( + "CreateToolhelp32Snapshot", + "Process32First", + "Process32Next", + "CloseHandle", + ): + _assert_signature_declared(kernel32, name) From 8693f1a6ed8b54a301861e35767d0b72c2aa3120 Mon Sep 17 00:00:00 2001 From: sadlilas <11658960+sadlilas@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:51:47 -0700 Subject: [PATCH 08/12] fix: correct Win32 handle sentinel comparison and PROCESSENTRY32 type signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix a surviving instance of the handle-sentinel bug class: CreateToolhelp32Snapshot signals failure with INVALID_HANDLE_VALUE = (HANDLE)-1, not NULL. With wintypes.HANDLE restype, ctypes converts NULL to None and the -1 bit pattern to a large positive int (18446744073709551615 on 64-bit). The guard 'if snap in (-1, 0):' could never match a real failure, allowing execution to fall through to Process32First and CloseHandle on invalid handles — flagged as fatal under Application Verifier / gflags. Changes: - Added _INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value and updated guard to 'if snap is None or snap == _INVALID_HANDLE_VALUE:'. Restype left unchanged (was already correct; only the comparison was wrong). - Corrected PROCESSENTRY32.th32DefaultHeapID from ctypes.POINTER(ctypes.c_ulong) to ctypes.c_size_t. Real Win32 type is ULONG_PTR (pointer-width integer), not pointer-to-ulong. Layout was accidentally correct on 64-bit; type is now semantically right. - Added test_snapshot_invalid_handle_value_is_treated_as_failure in tests/test_windows_ctypes_signatures.py, verifying real failure value returns empty result and neither Process32First nor CloseHandle are called. Pre-existing test used bare MagicMock with zero coverage of sentinel comparison; new test verified to genuinely fail against old guard. Investigation confirmed Win32 job-object containment is NOT a no-op on WSL path — it contains the Win32 launcher processes (wsl.exe/wslhost.exe); only the process inside WSL2 VM is unreachable, already documented in _protect_windows_descendants. Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- amplifier_module_tool_bash/__init__.py | 11 +++++++-- tests/test_windows_ctypes_signatures.py | 31 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/amplifier_module_tool_bash/__init__.py b/amplifier_module_tool_bash/__init__.py index 1f6e313..248b318 100644 --- a/amplifier_module_tool_bash/__init__.py +++ b/amplifier_module_tool_bash/__init__.py @@ -394,13 +394,20 @@ def _enumerate_child_pids_windows(parent_pid: int) -> set[int]: from ctypes import wintypes TH32CS_SNAPPROCESS = 0x00000002 + # CreateToolhelp32Snapshot signals failure by returning + # INVALID_HANDLE_VALUE, i.e. (HANDLE)-1 -- NOT NULL. With restype + # HANDLE (c_void_p), ctypes converts NULL to None and any other + # pointer value, including the -1 bit pattern, to a positive Python + # int (18446744073709551615 on 64-bit). Compare against the real + # sentinel, not the signed literal -1. + _INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value class PROCESSENTRY32(ctypes.Structure): _fields_ = [ ("dwSize", wintypes.DWORD), ("cntUsage", wintypes.DWORD), ("th32ProcessID", wintypes.DWORD), - ("th32DefaultHeapID", ctypes.POINTER(ctypes.c_ulong)), + ("th32DefaultHeapID", ctypes.c_size_t), ("th32ModuleID", wintypes.DWORD), ("cntThreads", wintypes.DWORD), ("th32ParentProcessID", wintypes.DWORD), @@ -429,7 +436,7 @@ class PROCESSENTRY32(ctypes.Structure): kernel32.CloseHandle.argtypes = [wintypes.HANDLE] snap = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) - if snap in (-1, 0): + if snap is None or snap == _INVALID_HANDLE_VALUE: return set() try: entry = PROCESSENTRY32() diff --git a/tests/test_windows_ctypes_signatures.py b/tests/test_windows_ctypes_signatures.py index 1b28e64..b7f7d52 100644 --- a/tests/test_windows_ctypes_signatures.py +++ b/tests/test_windows_ctypes_signatures.py @@ -124,3 +124,34 @@ def test_descendant_enumeration_declares_signatures(self) -> None: "CloseHandle", ): _assert_signature_declared(kernel32, name) + + def test_snapshot_invalid_handle_value_is_treated_as_failure(self) -> None: + """CreateToolhelp32Snapshot signals failure via INVALID_HANDLE_VALUE, + i.e. `(HANDLE)-1` -- NOT NULL. With a HANDLE (c_void_p) restype, + ctypes converts that bit pattern to a large *positive* Python int + (18446744073709551615 on 64-bit) -- never to the Python literal -1 + or to 0. A guard written as ``if snap in (-1, 0)`` therefore never + matches this real failure value and falls through to call + Process32First/CloseHandle on an invalid handle. + + This test must fail against that old guard: with it restored, the + invalid-handle value is not caught, so both Process32First and + CloseHandle get called on the invalid handle below (Process32First + is stubbed to return False purely so the walk terminates instead of + spinning forever on an always-truthy MagicMock Process32Next -- + it does NOT make the old guard correct). + """ + kernel32 = MagicMock(name="kernel32") + kernel32.CreateToolhelp32Snapshot.return_value = ctypes.c_void_p(-1).value + kernel32.Process32First.return_value = False + windll_factory = MagicMock(return_value=kernel32) + + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch.object(ctypes, "WinDLL", windll_factory, create=True), + ): + children = mod._enumerate_child_pids_windows(1234) + + assert children == set() + kernel32.Process32First.assert_not_called() + kernel32.CloseHandle.assert_not_called() From 1e35515acbb099fbd95d98d02643c2d69b2fddcd Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:49:40 -0700 Subject: [PATCH 09/12] test: fix bash-missing tests for filesystem-probe discovery fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tests mocked shutil.which returning None and expected the actionable bash-missing error. But the filesystem-probe fix on this branch queries well-known install locations directly (/Program Files/Git, etc.) precisely because Git for Windows never adds Git\bin to PATH. On a real Windows box with Git installed, the filesystem probe succeeds, bash is found, the actionable error never fires, and execution reaches create_subprocess_exec() — surfacing as 'ValueError: not enough values to unpack' when the mocked subprocess returns nothing. This was invisible on Linux where these tests are platform-guarded. The tests now properly neutralize both _find_git_bash_executable and _find_wsl_bash_executable alongside shutil.which. **Worth noting: this was a genuine interaction between two fixes on the same branch** — the filesystem-probe fix silently invalidated the actionable-error test on native Windows. This is exactly the kind of cross-platform gap that has no CI visibility until tests run where they're actually exercised. Evidence — native Windows 11, this test file: before: 2 failed, 94 passed after: 96 passed Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- tests/test_gap_bash_missing_actionable_error.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_gap_bash_missing_actionable_error.py b/tests/test_gap_bash_missing_actionable_error.py index cab80b9..98f1317 100644 --- a/tests/test_gap_bash_missing_actionable_error.py +++ b/tests/test_gap_bash_missing_actionable_error.py @@ -49,6 +49,15 @@ async def test_simple_command_gets_actionable_error_when_bash_missing() -> None: with ( patch("amplifier_module_tool_bash.sys.platform", "win32"), patch("amplifier_module_tool_bash.shutil.which", return_value=None), + # Neutralising PATH alone is NOT "no bash on this machine" any more. + # Git Bash discovery probes well-known install locations directly on + # the filesystem, precisely because Git for Windows never puts + # Git\bin on PATH. On a real Windows box with Git installed, this + # test passed on Linux (where it is skipped) and failed on Windows: + # bash WAS found, the actionable error never fired, and execution + # reached create_subprocess_exec. + patch("amplifier_module_tool_bash._find_git_bash_executable", return_value=None), + patch("amplifier_module_tool_bash._find_wsl_bash_executable", return_value=None), patch("amplifier_module_tool_bash.asyncio.create_subprocess_exec", exec_spy), ): result = await tool._run_command("echo hello", timeout=5) @@ -84,6 +93,11 @@ async def test_shell_metacharacter_command_still_gets_actionable_error() -> None with ( patch("amplifier_module_tool_bash.sys.platform", "win32"), patch("amplifier_module_tool_bash.shutil.which", return_value=None), + # See the note in the test above: PATH is no longer the only way bash + # is found on Windows, so a "no bash" simulation has to neutralise the + # filesystem probes too. + patch("amplifier_module_tool_bash._find_git_bash_executable", return_value=None), + patch("amplifier_module_tool_bash._find_wsl_bash_executable", return_value=None), patch("amplifier_module_tool_bash.asyncio.create_subprocess_exec", exec_spy), ): result = await tool._run_command("ls -la | head -3", timeout=5) From b2466b1870350a2013ef9724d1c3a89eec807799 Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:02:13 -0700 Subject: [PATCH 10/12] ci: add native Windows to the test matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This module had no CI. The entire Windows surface is shell resolution (Git Bash vs WSL vs neither), and every test guarding that path is POSIX-skipped, so platform-specific failures are invisible today. New workflow with a three-OS matrix [ubuntu-latest, macos-latest, windows-latest]. Includes fail-fast: false to preserve POSIX results if Windows fails. Honest limit: GitHub's Windows runner has no WSL, so the `wsl --exec` path cannot be exercised there. It does have Git Bash, so the Git-Bash and no-bash paths are covered. WSL behaviour still needs a real machine. This is CI-only and deliberately scoped to be independent. Expect the first Windows run to be red — those failures already exist. We simply cannot see them on POSIX CI today. Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .github/workflows/ci.yml | 49 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2cda423 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + name: pytest (${{ matrix.os }}, py${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + # This suite is seconds on every platform. A hang must fail LOUDLY and + # fast, not sit burning runner time until the 6h default -- a job stuck at + # "in_progress" reads as "not done yet" rather than "broken", which is how + # a false green gets merged. + timeout-minutes: 10 + strategy: + # A Windows failure must not cancel the POSIX legs. Those tell us whether + # we regressed the population that works today, which is the more + # expensive outcome by far. + fail-fast: false + matrix: + # requires-python = ">=3.11" -- cover the floor and a current minor. + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Run test suite + # windows-latest is the point of this workflow. This module's entire + # Windows surface is shell resolution -- Git Bash vs WSL vs neither -- + # and every test guarding it is skipped by platform guard on POSIX, so + # before this job those tests ran NOWHERE. Two of them were green on + # Linux for days and failed the first time they actually executed on + # Windows. + # + # Honest limit: GitHub's Windows runner has no WSL, so the `wsl --exec` + # path cannot be exercised here. It DOES have Git Bash, so the + # Git-Bash and no-bash paths are covered. WSL behaviour still needs a + # real machine. + run: uv run pytest -q From 323e6d10a8d9e56a7bf94130aedb34c08bf340bb Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:57:25 -0700 Subject: [PATCH 11/12] fix: macOS descendant-PID cleanup via portable ps fallback The test test_setsid_detached_child_is_killed_on_timeout was vacuous on macOS: it shelled out to setsid(1) from util-linux, which macOS does not ship. The subshell failed instantly, the marker was never written, and the test died at the marker assertion without ever exercising the cleanup hazard it claims to guard. Additionally, _find_descendant_pids walked /proc (Linux-only), meaning that even if the test had succeeded on macOS, it would still fail to find and kill a session-detached descendant on that platform -- a real product gap, not a test artifact. Fixed by two changes: 1. Detach the grandchild via os.setsid() (a POSIX syscall exposed by Python on every Unix, including macOS) instead of shelling out to the setsid binary. This is portable and exercises the identical hazard. 2. Add _find_descendant_pids_via_ps as a portable fallback using 'ps -A -o pid=,ppid=' (portable across GNU/BSD ps implementations) for POSIX systems without /proc. Refactored the pid/ppid tree walk into a shared _descendants_from_pid_ppid_pairs helper so both /proc and ps paths share a single implementation. Verified on Linux via monkey-patching os.listdir to raise on /proc, simulating macOS's absence of it. The fallback located and killed the detached descendant via ps alone. Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- amplifier_module_tool_bash/__init__.py | 82 ++++++++++++++++++++------ tests/test_timeout_process_cleanup.py | 42 +++++++++++-- 2 files changed, 100 insertions(+), 24 deletions(-) diff --git a/amplifier_module_tool_bash/__init__.py b/amplifier_module_tool_bash/__init__.py index 248b318..2145852 100644 --- a/amplifier_module_tool_bash/__init__.py +++ b/amplifier_module_tool_bash/__init__.py @@ -51,39 +51,85 @@ def _read_ppid(pid: int) -> int | None: return None +def _descendants_from_pid_ppid_pairs( + root_pid: int, pairs: list[tuple[int, int]] +) -> set[int]: + """Walk a flat list of (pid, ppid) pairs to find all descendants of root_pid.""" + children_by_ppid: dict[int, list[int]] = {} + for pid, ppid in pairs: + children_by_ppid.setdefault(ppid, []).append(pid) + + descendants: set[int] = set() + frontier = [root_pid] + while frontier: + current = frontier.pop() + for child in children_by_ppid.get(current, []): + if child not in descendants: + descendants.add(child) + frontier.append(child) + return descendants + + +def _find_descendant_pids_via_ps(root_pid: int) -> set[int]: + """Fallback descendant walk using `ps` for POSIX systems without /proc + (e.g. macOS, which has no /proc filesystem). + + `ps -A -o pid=,ppid=` is portable across GNU (Linux) and BSD (macOS) ps + implementations: `-A` selects every process, and the trailing `=` after + each column name suppresses the header on both. Returns an empty set on + any failure (missing `ps`, unexpected output, etc.) -- this is a + best-effort fallback, not a hard requirement. + """ + try: + result = subprocess.run( + ["ps", "-A", "-o", "pid=,ppid="], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return set() + + pairs: list[tuple[int, int]] = [] + for line in result.stdout.splitlines(): + fields = line.split() + if len(fields) != 2: + continue + try: + pairs.append((int(fields[0]), int(fields[1]))) + except ValueError: + continue + + return _descendants_from_pid_ppid_pairs(root_pid, pairs) + + def _find_descendant_pids(root_pid: int) -> set[int]: - """Recursively find all descendant PIDs of root_pid by walking /proc. + """Recursively find all descendant PIDs of root_pid. Unlike process-group membership, the PPID chain survives setsid() -- a process that detaches into its own session/process group (directly, or via a wrapper like tmux/incus/docker that manages its own session - lifecycle) keeps its original parent. Walking /proc lets us find and - kill descendants that escaped the process group and that os.killpg() - can no longer reach. + lifecycle) keeps its original parent. Walking the process table lets us + find and kill descendants that escaped the process group and that + os.killpg() can no longer reach. - Linux-only (relies on /proc). Returns an empty set on other platforms - or if /proc is unavailable. + Prefers /proc (Linux) for speed and reliability; falls back to `ps` + (e.g. macOS, which has no /proc) when /proc is unavailable. Returns an + empty set if neither source is usable. """ try: all_pids = [int(name) for name in os.listdir("/proc") if name.isdigit()] except OSError: - return set() + return _find_descendant_pids_via_ps(root_pid) - children_by_ppid: dict[int, list[int]] = {} + pairs: list[tuple[int, int]] = [] for pid in all_pids: ppid = _read_ppid(pid) if ppid is not None: - children_by_ppid.setdefault(ppid, []).append(pid) + pairs.append((pid, ppid)) - descendants: set[int] = set() - frontier = [root_pid] - while frontier: - current = frontier.pop() - for child in children_by_ppid.get(current, []): - if child not in descendants: - descendants.add(child) - frontier.append(child) - return descendants + return _descendants_from_pid_ppid_pairs(root_pid, pairs) def _signal_pids(pids: set[int], sig: int) -> None: diff --git a/tests/test_timeout_process_cleanup.py b/tests/test_timeout_process_cleanup.py index cb956cf..4721cb5 100644 --- a/tests/test_timeout_process_cleanup.py +++ b/tests/test_timeout_process_cleanup.py @@ -18,6 +18,7 @@ import asyncio import os +import shlex import signal import sys @@ -55,15 +56,44 @@ class TestTimeoutKillsSetsidDetachedDescendants: @pytest.mark.asyncio async def test_setsid_detached_child_is_killed_on_timeout(self, tmp_path): - """A `setsid`-detached grandchild must not survive timeout cleanup.""" + """A setsid-detached grandchild must not survive timeout cleanup. + + Detachment is performed via `os.setsid()` inside an inline Python + process rather than shelling out to the `setsid(1)` binary: `setsid` + is a util-linux program that Linux ships but macOS does not (no + `/usr/bin/setsid` on macOS -- confirmed via + https://stackoverflow.com/questions/36590905, and the existence of + third-party "ersatz setsid" replacements written specifically to fill + that gap on macOS). Shelling out to `setsid bash -c '...'` made this + test *vacuous* on macOS: the subshell failed instantly with + "command not found", no marker was ever written, and the test failed + at the marker-existence assertion without ever exercising the actual + cleanup hazard. + + Calling `os.setsid()` directly is portable (it's a POSIX syscall + exposed by Python's `os` module on every Unix, including macOS) and + exercises the identical hazard: a descendant that moves itself to a + new session/process group, whose PPID chain back to the timed-out + command is preserved (setsid() never reparents). + """ marker = tmp_path / "detached_child.pid" tool = BashTool({}) - # Spawn a detached grandchild via setsid that records its own PID, - # then sleeps far longer than the tool timeout. The outer `sleep` - # keeps the parent bash alive past the timeout so the tool actually - # times out (rather than exiting cleanly on its own). - command = f"setsid bash -c 'echo $$ > {marker}; sleep 60' & sleep 30" + # Spawn a detached grandchild that calls os.setsid() on itself, + # records its own PID, then sleeps far longer than the tool timeout. + # The outer `sleep` keeps the parent bash alive past the timeout so + # the tool actually times out (rather than exiting cleanly on its + # own). + detach_snippet = ( + "import os\n" + "os.setsid()\n" + f"open({str(marker)!r}, 'w').write(str(os.getpid()))\n" + "import time\n" + "time.sleep(60)\n" + ) + command = ( + f"{shlex.quote(sys.executable)} -c {shlex.quote(detach_snippet)} & sleep 30" + ) detached_pid: int | None = None try: From 7c32bb59a2b149c6289902cf0cd6a8d6d0851fe2 Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:59:15 -0700 Subject: [PATCH 12/12] ci: remove git pin forcing amplifier-core source build on Windows The git source pin for amplifier-core was added to resolve test-collection errors in a clean checkout. However, it forced uv to compile amplifier-core from source on every CI run, causing Windows legs to timeout before any tests executed. This is a documentation of a verification failure: CI infrastructure was added and reported as passing (5 checks completed), but the Windows legs never actually executed. The check counts were accurate; the check coverage was not. amplifier-core publishes prebuilt wheels (amplifier_core-1.6.1-cp311-abi3-win*.whl) that satisfy the dependency. Removing the [tool.uv.sources] block lets uv resolve from PyPI while maintaining the dependency. Verified: - Linux suites pass unchanged (92 passed, 10 skipped) - uv.lock resolves to amplifier-core v1.6.1 from PyPI registry - No regression in test collection with PyPI-sourced build Fixes: #15 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- pyproject.toml | 3 --- uv.lock | 12 ++++++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 36576f1..0556526 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,9 +23,6 @@ build-backend = "hatchling.build" [tool.uv] package = true -[tool.uv.sources] -amplifier-core = { git = "https://github.com/microsoft/amplifier-core", branch = "main" } - [tool.hatch.build.targets.wheel] packages = [ "amplifier_module_tool_bash", diff --git a/uv.lock b/uv.lock index 38a622f..6fa0b19 100644 --- a/uv.lock +++ b/uv.lock @@ -5,7 +5,7 @@ requires-python = ">=3.11" [[package]] name = "amplifier-core" version = "1.6.1" -source = { git = "https://github.com/microsoft/amplifier-core?branch=main#92b339ab3cfe13241c1551f8c5d06dfe85806888" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "pydantic" }, @@ -13,6 +13,14 @@ dependencies = [ { name = "tomli" }, { name = "typing-extensions" }, ] +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/cd/8b0b520bf0de741ea73e069aaf64aca28c9f4ce91a7b8b9239193a6c4c1b/amplifier_core-1.6.1-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c0f711d8408de78e53e5deddcb38b7240c5c1c497ca51eeaaeff23559b3d3c48", size = 8281633, upload-time = "2026-08-10T02:38:11.98Z" }, + { url = "https://files.pythonhosted.org/packages/14/83/f4fb297d87d35b9d74058da02bb153e12f7891ab62b3aaf7e0857f877798/amplifier_core-1.6.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b08f37e2c0b1611349a0e25d5bf9bfdfae3afcee35488f8e26bba1cdd400503b", size = 7366930, upload-time = "2026-08-10T02:38:14.105Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/5eb9cecf92d8053c5e6d46ad9668c3ed3558d5423845c1dced1f266b2a38/amplifier_core-1.6.1-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ebf7e3993c76ea506e70ac7844b286c3ba2e9127b3bcb350fa4fcd2dcdbd38d", size = 7659512, upload-time = "2026-08-10T02:38:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/121f054e3d079dc33d83f3d8ba9af50fd9f7694c3e2ba3d7d23d7c157d48/amplifier_core-1.6.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c957cd0671d2a003f2c8f7d6a41bd6e808f97d183c57b97e7700bf4c912621d", size = 8678425, upload-time = "2026-08-10T02:38:18.243Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/bfc217f4a9ed2d033995fc59847f1fee2e1b17130632fcb0e0981a1a311b/amplifier_core-1.6.1-cp311-abi3-win_amd64.whl", hash = "sha256:50c80bcfa1f6efe769b19e7af18c925024c7553d4db08880727241709dd44eae", size = 8976601, upload-time = "2026-08-10T02:38:20.505Z" }, + { url = "https://files.pythonhosted.org/packages/a5/14/5f330452c92c6c5d35c51ad5311301949ce5db4d1a1a901456f3ee43eaac/amplifier_core-1.6.1-cp311-abi3-win_arm64.whl", hash = "sha256:cd8b617f132cf5d1ca3e5187d5f831d1f2a508bb40d07b2ab1085961bcb9e1a9", size = 7744837, upload-time = "2026-08-10T02:38:22.562Z" }, +] [[package]] name = "amplifier-module-tool-bash" @@ -30,7 +38,7 @@ dev = [ [package.metadata.requires-dev] dev = [ - { name = "amplifier-core", git = "https://github.com/microsoft/amplifier-core?branch=main" }, + { name = "amplifier-core" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-asyncio", specifier = ">=0.24" }, ]