Skip to content

wsl.exe Eats Your Shell Variables When the Script Is an Argument

Problem

You call into WSL from the Windows side and pass a shell script as an argument:

subprocess.run([r"C:\WINDOWS\system32\wsl.exe", "bash", "-c", script])

The script runs, exits cleanly, and returns the wrong answer — with rc=0, empty stderr, and no indication anything went wrong. Any variable the script assigns itself comes back empty:

for d in aa bb; do echo "ITER=[$d]"; done; x=hi; echo "VAR=[$x]"

  argv form  →  ITER=[]   ITER=[]   VAR=[]      rc=0
  stdin form →  ITER=[aa] ITER=[bb] VAR=[hi]    rc=0

The loop iterates the right number of times. The quoting survives. Only the values are gone.

This is nastier than a crash. A search loop like this:

for d in "$HOME/repo" "$HOME/Documents/repo"; do
  if [ -f "$d/marker.yml" ]; then printf %s "$d"; exit 0; fi
done; exit 1

exits 1 with empty stdout and empty stderr — indistinguishable from an honest "not found," even though the file is right there. You will go looking for the file, confirm it exists, and be no wiser.

Cause

wsl.exe subjects the command line to an extra round of shell expansion before the target program receives it.

Variables that exist in the surrounding environment ($HOME, $USER) get substituted with their real values — which is why simple one-liners work and lull you into trusting the pattern. Variables the script defines for itself ($d, $x, loop variables, anything assigned mid-script) are not set at that point, so they expand to the empty string and are gone by the time bash parses the script.

Two things this is not, both worth ruling out because they're the natural first guesses:

  • Not a quoting/word-splitting problem. Spacing and quotes are preserved intact — echo "A B"; echo "C D" round-trips with both spaces.
  • Not caller-specific. It reproduces identically from Python subprocess with a plain argv list (no shell=True), from Python with shell=True, and from an interactive bash on either side. It is wsl.exe itself, not your language's argument handling.

Solution

Deliver the script on stdin instead of as an argument. bash -s reads the script from standard input, which no layer touches:

subprocess.run(
    [wsl, "bash", "-s"],
    input=script,
    capture_output=True, text=True,
    encoding="utf-8", errors="replace",
)
# shell equivalent
printf '%s' "$script" | wsl.exe bash -s

Nothing else changes — exit codes, stdout and stderr all behave normally. The script is delivered byte-for-byte.

Set encoding="utf-8", errors="replace" while you're there: wsl.exe's own error messages (as opposed to your program's output) are UTF-16, so a decode error can mask the real failure.

Diagnosis

If you suspect this, run the canary above rather than debugging your real script:

wsl.exe bash -c 'x=hi; echo "VAR=[$x]"'

VAR=[hi] means you're fine. VAR=[] means every self-assigned variable in your script is being blanked, and you should move to stdin delivery.

Reach for this immediately whenever a WSL-crossing script returns a plausible-but-wrong result with a clean exit and no error output.

Where this bites

Anything on Windows that shells into WSL for tooling that only exists there — Ansible, a git checkout, a credential store. In particular, Claude Desktop plugin/MCP servers on a Windows host run Windows-side, so any of them reaching into WSL for ansible-vault, a repo path, or a password file hits this. That is exactly how it surfaced here: the Namecheap plugin's repo-discovery loop reported "checkout not found" for a checkout that existed.