WSL bash scripts: kill CRLF, or route through a temp file
When to use
You're driving a Linux toolchain from Windows: a PowerShell or .bat
script builds a multi-line bash body and runs it in WSL via
wsl.exe -d <distro> -- bash -c "<body>". Symptoms that land you
here:
bash: line 1: set: pipefail : invalid option name(note the phantom space before the:— that's a literal\r).bash: line N: $'<something>\r': command not found.- Heredoc bodies that silently truncate at the wrong line.
- A bash
if/thenblock reports a syntax error at a line that looks perfectly fine in your editor.
It's not "my bash is wrong"; it's CRLF contamination crossing the Windows → WSL boundary.
Problem
PowerShell's here-strings (@' … '@, @" … "@) and .bat echo
lines emit CRLF by default. When you hand the resulting string
to wsl.exe bash -c "$body", Linux bash reads the \r as a
normal character that is part of the previous token:
set -euo pipefail\rbecomes the commandsetwith options-e,-u,-o,pipefail\r. bash printsset: pipefail : invalid option name— the "space" before the colon in the error message is actually the carriage return.FOO=bar\rsetsFOOtobar\r, and the later comparison[ "$FOO" = "bar" ]silently fails.fi\r/done\rare accepted as words, so theif/foris never closed and the parser reports an error many lines below the actual cause.
Why it's easy to miss:
- Your editor (VS Code, Notepad++, etc.) happily hides
\r. - The CRLF shows up only when the script crosses into WSL. If
you run the same body via
Invoke-Expressionon Windows, or dump it to disk andtypeit, everything looks fine. - The usual quoting-skills reflex (escape the dollar signs, quote the body, etc.) doesn't help — this is a line-ending issue, not a quoting issue.
The related \0 NUL trap at the API boundary is a different
problem (see Pitfalls below).
Solution
Two reliable fixes. Prefer #2 for anything beyond a one-liner — it composes better and gives you a debuggable artifact.
Fix 1 — Normalize to LF before handing to bash -c
Strip \r from the body immediately before invocation:
$bash = $bashTemplate `
-replace '__PLACEHOLDER__', $value
# Force LF endings. Order matters: CRLF first, then any lone CR.
$bash = $bash -replace "`r`n", "`n" -replace "`r", "`n"
& wsl.exe -d $Distro -- bash -c $bash
Works, but still has two weaknesses:
- Long bodies are passed as a single command-line argument; quoting
and escaping interact with
cmd→wsl.exe→bash -cacross three layers. - There's nothing on disk to inspect when it fails.
Fix 2 — Write to a temp file (UTF-8 no BOM, LF), run bash <path>
Reliable for any script size:
# Normalize endings.
$bash = $bash -replace "`r`n", "`n" -replace "`r", "`n"
# UTF-8 *without* BOM — Out-File / Set-Content may add a BOM,
# which bash interprets as an unrecognised character on line 1.
$tmp = Join-Path $env:TEMP ("fbc-sweep-{0}.sh" -f ([IO.Path]::GetRandomFileName()))
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[IO.File]::WriteAllText($tmp, $bash, $utf8NoBom)
# Translate C:\Users\foo\... -> /mnt/c/Users/foo/...
$drive = $tmp.Substring(0,1).ToLowerInvariant()
$tail = $tmp.Substring(2).Replace('','/')
$wslPath = "/mnt/$drive$tail"
try {
& wsl.exe -d $Distro -- bash $wslPath
$exit = $LASTEXITCODE
} finally {
Remove-Item -LiteralPath $tmp -ErrorAction SilentlyContinue
}
Why this one works:
- A real file with LF endings is what
bashexpects. No CRLF handling to get wrong. bash <path>means no-c "<huge string>": no quoting / escaping issues, no command-line-length limit, no weird interactions withcmd.exe's percent expansion.- The temp file is your debuggable artifact. If WSL prints an
error, just
wsl bash -x <path>(orcat -A <path>) to inspect exactly what bash saw. try / finallyensures cleanup even on Ctrl-C or exceptions.
Verification one-liner
Before you trust the pipeline, verify with cat -A:
& wsl.exe -d $Distro -- bash -c "cat -A '$wslPath' | head -n 3"
Good output (LF everywhere):
set -euo pipefail$
echo ok$
Bad output (CRLF — the ^M is the \r):
set -euo pipefail^M$
echo ok^M$
If you see ^M$, the LF normalization didn't run or wasn't
applied to the content you actually wrote.
Example
Broken — heredoc straight into bash -c:
$bash = @'
set -euo pipefail
echo hello
'@
& wsl.exe -d Ubuntu-24.04 -- bash -c $bash
# bash: line 1: set: pipefail : invalid option name
Fixed — normalize + temp file:
$bash = @'
set -euo pipefail
echo hello
'@
$bash = $bash -replace "`r`n","`n" -replace "`r","`n"
$tmp = Join-Path $env:TEMP ("wsl-run-{0}.sh" -f ([IO.Path]::GetRandomFileName()))
[IO.File]::WriteAllText($tmp, $bash, (New-Object System.Text.UTF8Encoding($false)))
$wslPath = "/mnt/" + $tmp.Substring(0,1).ToLowerInvariant() + $tmp.Substring(2).Replace('','/')
try { & wsl.exe -d Ubuntu-24.04 -- bash $wslPath }
finally { Remove-Item -LiteralPath $tmp -ErrorAction SilentlyContinue }
# hello
Pitfalls
- Don't use
Out-File/Set-Contentwithout specifying encoding. Windows PowerShell 5.1 defaults to UTF-16 LE with BOM; PowerShell 7 defaults to UTF-8 no BOM — but relying on that difference is fragile. Always passUTF8Encoding($false)explicitly via[IO.File]::WriteAllText, orSet-Content -Encoding utf8NoBOMon PowerShell 7+. A BOM on line 1 makes bash report an error on the#!/ first command. - The
wsl.exe --listNUL trap is a separate bug, same family.wsl.exe --list --verboseemits UTF-16 LE; PowerShell decodes it into a string, but leaves a\0byte between each character. Exact-string matching against a distro name fails for a reason totally invisible in the printout. Cure: pre-strip NULs with a-replacepass (replace the literal`0escape with the empty string) before any regex /-containscheck. Symptomatically similar ("my match is right but the code says it isn't"), mechanistically different (\0at the API boundary vs\rinside the payload) — fix both. - Don't try to pipe via
echo | wsl bash.echoincmd.exeemits CRLF, and the pipe insidecmddoesn't translate. You end up back at the original bug. Eitherwsl.exe bash <file>orwsl.exe bash -c "<LF-only body>". -lcvs-cwon't save you. Login shells don't strip\r; they just source more rc files. Fix the content, not the shell invocation flags.- Mixed CRLF in existing repo scripts. If the
.shfile lives in the repo and was edited on Windows,git config core.autocrlfmay have rewritten it to CRLF on checkout. Either set* text eol=lfin.gitattributesfor*.sh(persistent cure) or normalize once withdos2unixbefore invoking. This is the same family of bug as above but the source is the checkout, not the PowerShell heredoc. - WSL networkingMode fallbacks are unrelated noise. Messages
like
wsl: Failed to configure network (networkingMode Mirrored), falling back to networkingMode Noneoften appear alongside theset: pipefailerror and look causal, but they're not. Fix the CRLF first; if your bash script then fails atapt-get update, that is when to investigate the network config. - Don't trust your editor. "But I can see
set -euo pipefailis on one line!" — yes, because VS Code hides\r. Usecat -A(orod -c) inside WSL on the exact file the agent produced, not on a copy you opened in your editor.
See also
- shell-heredoc-and-multiline-strings — sibling skill about passing multi-line strings inside a single shell (bash → git / gh), not across the Windows → WSL boundary.
- workspace-path-constraints — why the temp file must end up reachable from both sides.