fix(sessions): PATHEXT-aware Windows executable resolution on both spawn paths - #286
Merged
mwiebe merged 2 commits intoJul 29, 2026
Merged
Conversation
mwiebe
force-pushed
the
fix/windows-executable-resolution
branch
from
July 28, 2026 00:35
6f12a10 to
6d13526
Compare
crowecawcaw
approved these changes
Jul 29, 2026
Comment on lines
+99
to
+103
| // On Windows, hard-link the real interpreter as `python.exe` so | ||
| // it resolves correctly under PATHEXT semantics without the | ||
| // argument-mangling issues of a .cmd/.bat wrapper. Hard link | ||
| // avoids a ~5 MB copy and works as long as source and dest are on | ||
| // the same volume (both are under %TEMP% / %LOCALAPPDATA%). |
Contributor
There was a problem hiding this comment.
From the PR description, it sounded like this change is supposed to fix resolution issues so we don't need stuff like this? I might be misinterpreting something though.
Contributor
Author
There was a problem hiding this comment.
I'm of a mind to remove this python proxy thing entirely, but I haven't looked closely at what it's doing.
| //! always reads PATHEXT from the process environment, and it accepts any | ||
| //! existing file when the command has an explicit extension — whereas | ||
| //! `shutil.which` (and cmd.exe) treat an extension outside PATHEXT as | ||
| //! not-runnable and report not-found. |
Contributor
There was a problem hiding this comment.
Is it possible to run which in a separate process that mirrors the action so we don't have this reimplementation?
Contributor
Author
There was a problem hiding this comment.
which isn't a program generally available on Windows, and running a subprocess here would be a bigger performance degradation than I think is reasonable.
…awn paths Rust's std::process::Command is not a faithful Windows command search: it only probes `.exe` for bare names (never consulting PATHEXT), and when the child environment's PATH has no match, CreateProcessW falls back to a legacy search (application directory, system directories, the parent process's PATH — even after env_clear). Exploratory testing confirmed three resulting defects, all fixed here by resolving the command to an absolute path before spawn: 1. A `.bat` in an earlier PATH directory now correctly wins over an `.exe` in a later directory (canonical Windows search order, matching cmd.exe, where.exe, and Python's shutil.which). 2. Commands absent from the action's PATH fail with the Python-parity "Could not find executable file" error instead of silently resolving through the worker process's own environment. 3. The session working directory is searched first (Python's `working_dir;PATH`), so embedded-file scripts run by bare name. The search implements shutil.which semantics in a single source file (helper/src/win32_which.rs) compiled into both binaries — the session crate includes it via #[path] — so the two spawn paths cannot drift. It is hand-written rather than using the `which` crate because `which` cannot honor the action's environment: it always reads PATHEXT from the resolving process, and it accepts any existing explicitly-suffixed file (script.ps1 would resolve and then die at spawn with error 193, where shutil.which reports not-found). PATH and PATHEXT each follow an either/or source rule, never a chain: an action-supplied value is used exclusively; only an entirely absent key selects the base environment's value — mirroring the environment merge applied at spawn. Same-user actions resolve host-side via win32_locate.rs (rewritten: PATH-fallback bug fixed, not-found now a hard error, unused user param removed). Cross-user actions resolve inside the embedded helper (runner_win.rs), which runs as the target user — it can probe directories only that user can read, and its no-override fallback is the target user's own environment, the exact base environment the workload inherits. Tests: unit tests in win32_which.rs (run in both the session and helper suites) and win32_locate.rs; end-to-end same-user tests in test_win32_locate.rs (including action-PATHEXT restriction and .ps1-outside-PATHEXT not-found); helper-protocol tests in test_helper.rs; cross-user session tests in test_cross_user_windows.rs for the CI test-user job (working-dir .bat by bare name, not-found via protocol, resolution in a target-user-readable protected directory); and newline-argument rejection tests on both paths pinning std's BatBadBut (CVE-2024-24576) refusal surfaces as a clear error. Also fixes the CLI test harness's Python shim: a .cmd wrapper breaks argument passing for multi-line one-liners, replaced with a hard-linked .exe. Specs: win32-locate.md rewritten around the integrated design (including the shared-search-implementation and PATHEXT semantics sections); cross-user.md, architecture.md, subprocess.md, and embedded-cross-user-helper.md updated — Windows cross-user support is now documented as fully implemented and tested, replacing the stale "partially implemented" claims. Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
…ution Code-review finding: locate_windows_executable collapsed an explicit unset (Some(None) in os_env_vars, e.g. from an openjd_unset_env directive) into the same state as an absent key, falling back to the process environment's PATH. At spawn the merge removes the variable from the child, so a bare command could resolve from directories the action deliberately dropped — the exact worker-environment leak this module exists to prevent. Same-user path only; the helper protocol's env map has unsets filtered out before dispatch. The lookup now preserves all three states: absent → process value (matching what the child inherits), set → used exclusively, explicitly unset → PATH resolves as empty (working directory only) and PATHEXT as the default extension list (matching cmd.exe and shutil.which in a child with no PATHEXT). Documented in win32-locate.md, including the intentional divergence from Python's _get_path_var_for_shutil_which, which conflates unset with absent. Unit tests: unset PATH must not fall back to the process PATH, unset PATH still searches the working directory, unset PATHEXT selects the default extension list. Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
mwiebe
force-pushed
the
fix/windows-executable-resolution
branch
from
July 29, 2026 19:34
c139a73 to
e16db57
Compare
mwiebe
enabled auto-merge (squash)
July 29, 2026 19:36
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was the problem/requirement? (What/Why)
Background: how do you turn
command: pythoninto a running process on Windows?When an OpenJD template says
command: python, something has to figure outwhich file on disk that means. On Windows this is surprisingly subtle,
because Windows has two rules that Linux doesn't:
pythonmight bepython.exe, but a runnablecommand can also be a
.bat,.cmd, or other extension. Windows keepsthe list of runnable extensions in the
PATHEXTenvironment variable.When cmd.exe (or
where.exe, or Python'sshutil.which) looks up a barename, it walks the
PATHdirectories one at a time and tries everyPATHEXT extension in each directory before moving to the next. So a
tool.batin an early PATH directory beats atool.exein a later one.Our sessions runtime spawned commands with Rust's
std::process::Command,which does not follow those rules. It only ever tries
.exefor a barename, and — worse — when the child's PATH has no match, the underlying
CreateProcessWcall quietly falls back to a legacy search: the applicationdirectory, the system directories, and the parent process's PATH (even
when we explicitly cleared the child's environment).
Exploratory testing confirmed three real defects:
dirA\tool.batanddirB\tool.exeonPATH=dirA;dirB, every standard Windows resolver picks the.batindirA; we picked the.exeindirB. A bare name whose only match wasa
.bat/.cmddidn't resolve at all.run, silently resolved through the worker agent's own environment. An
action's command should see exactly the environment the action defines.
searches the session working directory first (
working_dir;PATH), so ascript materialized as an embedded file is runnable by bare name. We
never searched it.
The crate already contained a module for this (
win32_locate.rs, mirroringPython's
_locate_executable.py), but it was dead code — never wired intothe spawn paths — and had a bug in its PATH fallback. The specs described
Windows cross-user support as "partially implemented", which was also stale:
the embedded cross-user helper is fully implemented and CI-tested on Windows.
What was the solution? (How)
Resolve the command to an absolute path before spawning. Absolute paths
bypass all of
CreateProcessW's fallback machinery, so getting the lookupright once fixes all three defects at the same time.
There are two spawn paths, and each resolves in the right place:
Same-user actions (
run_subprocessinsubprocess.rs) resolvehost-side via the rewritten
win32_locate::locate_windows_executable:PATHEXT-aware search over
{working_dir};{PATH}, earliest directory wins,and "not found" is now a hard error with the same message Python raises
(
Could not find executable file: <command>).Cross-user actions resolve inside the embedded helper binary
(
helper/src/runner_win.rs::locate_executable), which runs as the targetuser. That placement matters for two reasons:
only the target user can read (Python achieves the same by spawning a
shutil.whichprobe as the target user; our helper just does itin-process since it already is that user).
the target user's PATH (from its environment block) — so resolution
must use that PATH, not the service user's. Host-side resolution would
look at the wrong one.
One rule worth calling out: the PATH used for the search comes from exactly
one source, chosen up front — if the action supplies a PATH it is used
exclusively (even if the search then finds nothing), and only a completely
absent PATH falls back to the base environment. It is never a
try-one-then-the-other chain. This mirrors how the environment merge works
at spawn, so resolution always searches the PATH the child actually sees.
Both paths compile the same search implementation from one shared source
file (
helper/src/win32_which.rs, included into the session crate via#[path]), so their semantics are identical by construction. The search ishand-written with
shutil.whichsemantics rather than using thewhichcrate, because
whichcannot honor the action's environment: it alwaysreads PATHEXT from the resolving process (an action setting
PATHEXT=.EXEwould still match a
.bat), and it accepts any existing file when thecommand has an explicit extension (
script.ps1would resolve and then dieat spawn with error 193, where
shutil.whichcorrectly reports not-found).Like PATH, PATHEXT follows the either/or source rule: the action's value is
used exclusively when set, else the base environment's.
A test-harness fix rode along: the CLI tests shimmed
pythonwith apython.cmdwrapper, which the (previously broken) resolution never found.Once resolution started finding it, tests with multi-line
python -cone-liners failed with
batch file arguments are invalid: since theBatBadBut fix (CVE-2024-24576), Rust's std refuses to spawn a
.bat/.cmdwith any argument it cannot safely escape for cmd.exe, and cmd.exe has no
escape for an embedded newline (passed raw, it silently truncates the
argument at the newline). The shim is now a hard-linked
python.exe.What is the impact of this change?
matching cmd.exe,
where.exe,shutil.which, and the Python OpenJDimplementation. Templates using
.bat/.cmdcommands by bare name, orscripts placed in the session working directory, now work.
instead of silently running a binary from the worker's own environment.
This is a behavior change: jobs that (likely unknowingly) depended on the
legacy fallback search will now fail with
Could not find executable file: <command>— which is the same behavior as the Pythonimplementation, and the failure message makes the cause obvious.
they document what is actually built and tested.
How was this change tested?
Yes —
cargo test --workspaceandcargo clippy --all-features --all-targets --workspace -- -D warnings(plus the helper sub-crate's ownclippy, as CI runs it) are clean on Windows. The two pre-existing failures
on this (domain-joined) dev machine —
test_with_password_nonexistent_user_ returns_logon_failureandtest_tempdir_windows_nonvalid_principal_raises_ error— fail identically onmainand are environmental (LogonUserWreturns "domain isn't available" instead of
ERROR_LOGON_FAILURE); theypass on CI's standalone runners.
New coverage, written to fail before the fix and pass after:
win32_locate.rs: PATHEXT precedence, working-dirprecedence, case-insensitive PATH key lookup, process-PATH fallback,
absolute-path passthrough, not-found error.
tests/integration/test_win32_locate.rs: the same behaviorsend-to-end through
Session::run_subprocess, including the "absent fromthe action's PATH must fail" guarantee that proves the legacy fallback
search is bypassed.
tests/integration/test_helper.rs: drives the helper binary directlyover its stdin/stdout protocol — PATHEXT precedence, cwd-first,
not-found error over the protocol, helper-PATH fallback.
tests/integration/test_cross_user_windows.rs(CI test-user job):working-dir
.batby bare name through the helper, not-found via theprotocol, and resolution of an executable in a directory with a
protected DACL readable by the target user — the case that specifically
requires target-user-vantage resolution.
Was this change documented?
Yes. Doc comments on both resolution functions state the search rules and
the either/or PATH-source selection. Spec updates:
win32-locate.md(rewritten around the integrated design),
subprocess.md(new resolutionsection),
cross-user.mdandarchitecture.md(stale "partiallyimplemented" / "not yet integrated" claims replaced with the actual state,
including CI testing), and
embedded-cross-user-helper.md(new "Windowsrunner: executable resolution" section).
Is this a breaking change?
No public API changes. There is one intentional behavior change on Windows,
described above: commands not on the action's PATH now fail with a clear
error instead of silently resolving through the worker process's
environment. This aligns with the Python implementation's behavior.
Does this change impact security?
This change provides the following improvement: previously, an action whose
PATH did not contain its command could silently execute a binary from the
worker agent's own PATH, the application directory, or the system
directories — an environment the job never declared. Resolution to an
absolute path before spawn eliminates that ambient lookup on both spawn
paths. No new files or directories are created; the helper binary's
protections (protected DACL, Job Object) are unchanged.
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.