Repository navigation
Description
Summary
wmux.cmd forwards its arguments with %*, which re-exposes them to cmd.exe's parser. When the caller is PowerShell, the quoting the user wrote is stripped before cmd.exe sees the line, so any >, |, & or < inside a free-form argument is interpreted as shell syntax instead of reaching the CLI.
The > case is the bad one: it silently succeeds with the wrong result and writes a stray file into the user's cwd. Exit code 0, no output, no warning.
Lines 6 to 10 in fae21a5
| if defined WMUX_CLI ( | |
| node "%WMUX_CLI%" %* | |
| ) else ( | |
| node "%~dp0..\cli\wmux.js" %* | |
| ) |
The bash shim next to it is already correct — it uses "$@":
Lines 7 to 8 in fae21a5
| CLI="${WMUX_CLI:-$(cd "$(dirname "$0")/../cli" && pwd)/wmux.js}" | |
| exec node "$CLI" "$@" |
Repro
From PowerShell, with a browser pane open (the current page's title happens to be 24 characters):
PS> wmux browser eval "document.title.length>0"
PS> $LASTEXITCODE
0No output. It looks like a no-op. It was not:
PS> Get-Content .\0
{
"result": 24
}document.title.length was evaluated, and cmd.exe redirected the CLI's stdout into a new file named 0 in the working directory. The user asked a yes/no question, got silence, and gained a junk file.
Other separators, same cause:
PS> wmux browser eval "1>2" # exit 0, silent, creates a file named "2" containing {}
PS> wmux browser eval "a|b" # exit 255
'b' is not recognized as an internal or external command,
operable program or batch file.
PS> wmux browser eval '"x"&"y"' # exit 1
Error: Uncaught
'"y"' is not recognized as an internal or external command,
operable program or batch file.That last one is particularly confusing to debug — cmd.exe reports a command the user never typed, in the middle of what looks like CLI output.
It is the shim, not the CLI
Bypassing the shim works fine:
PS> node "$env:LOCALAPPDATA\Programs\wmux\resources\cli\wmux.js" browser eval "1+1"
{ "result": 2 }And so does cmd.exe itself, because cmd does not parse redirection inside a quoted token — the quotes survive when they are typed in cmd:
C:\> wmux browser eval "document.title.length>0"
{ "result": true }
So the trigger is specifically PowerShell → .cmd → %*: PowerShell strips the quotes when invoking a native .cmd, and %* then hands the bare > straight back to cmd.exe.
Verified on v0.46.0, Windows 11, PowerShell 7. The shipped resources/cli-bin/wmux.cmd is byte-identical to src/cli-bin/wmux.cmd on master.
Scope
This is not a browser eval bug — eval is just the easiest place to see it, because it echoes its argument back. Every command declared passthrough: true takes free-form text and is exposed:
browser (type / fill / eval), send, notify, log, markdown set --content, rename-workspace, rename-surface, ssh
A couple that would bite in ordinary use:
wmux send "npm run build > out.log" # redirects on the HOST, sends the truncated text
wmux notify "Build & deploy finished" # cmd splits at &, tries to run "deploy finished"wmux send is the one I would worry about most: it silently sends different text than the user wrote into a live terminal.
Why it is easy to miss
Agents drive wmux from Bash (correct shim) far more often than from PowerShell, and the failure is silent in exactly the case a human is most likely to hit — a > comparison in a quick eval. I only found it while chasing a vague "browser eval dies from PowerShell" report; the reporter's symptom was Access is denied., which is what you get when the redirect target resolves somewhere unwritable.
Fix direction
I did not want to guess at your preference here, since the robust options differ in cost:
- Re-quote inside the
.cmd. Loop the arguments and wrap each in quotes before handing them to node. Keeps one shim, but batch argument re-quoting is genuinely fiddly (%*is raw;%1..%9caps out and loses quoting nuances of its own). - Ship a
wmux.ps1alongside, so PowerShell resolves that instead and passes@argsthrough natively without a cmd round-trip. Cleanest for the affected caller, at the cost of a third shim to keep in step. - Leave the shim and document it — I would argue against this one given the
>case is silent.
Happy to implement whichever you prefer, with a test. I would lean to 2, since it removes the cmd.exe parser from the PowerShell path entirely rather than trying to out-escape it, and the bash shim already sets the precedent of one shim per shell family.
Activity
fix(cli): keep cmd.exe's parser out of the PowerShell path (#154)
amirlehmam commentedon Aug 13, 2026
Fixed in 0.50.0, and you were right to lean to option 2 — but for a stronger reason than "cleanest for the affected caller". Option 1 is not merely fiddly, it is impossible, and option 2 needed a gate before it was safe to ship.
Option 1 cannot work, and it's worth knowing why
I tried it before agreeing with you, because "re-quote inside the .cmd" is the fix that doesn't add a file. The standard robust idiom — capture each argument with
%~1while delayed expansion is off, re-quote it, invoke through!ARGS!so the expansion is never re-parsed:From PowerShell, that still loses
document.title.length>0, still creates a file named0, still breaksa|bandx&&y. Because the redirect is applied to cmd.exe's own invocation line, before the batch file is entered — so%1never contains a>; the argument was already gone when the shim started. Nothing inside a.cmdcan recover what cmd.exe consumed on the way in. (Interestingly the cases you listed as hypothetical —notify "Build & deploy finished"— survive today, because PowerShell does quote arguments containing spaces. It's the space-free ones, the>comparison you found, that go through bare.)So the shell has to be removed from the path, not out-escaped.
wmux.ps1,@args.Why the .ps1 could not just be shipped
A .ps1 PowerShell refuses to run is a hard error, and PowerShell does not fall back to the
wmux.cmdsitting beside it — resolution has already committed. Two ordinary conditions produce exactly that:Restrictedis the Windows PowerShell 5.1 default on client Windows.:Zone.Identifier; underRemoteSigned(the pwsh default) that is "remote and unsigned", and wmux has no chain-trusted signature to offer.Measured, not assumed:
Shipping the shim unconditionally would have traded your silent-wrong-result for a
wmuxthat does not run at all in PowerShell. Strictly worse.What shipped
src/cli-bin-ps/holdswmux.ps1plus awmux-shim-probe.ps1whose only job is to print a marker. At startup wmux strips the Mark of the Web from both (best effort), then executes the probe in every installed host —powershell.exeandpwsh.exe— from a process with any inherited-ExecutionPolicy Bypasscleared, since wmux launches its own panes with Bypass and inheriting that would make the probe pass for a machine where a fresh shell would refuse. Only if every installed host prints the marker does the dir get prepended to PATH, ahead ofcli-bin. Otherwise the dir is simply never on PATH and PowerShell keeps resolvingwmux.cmdexactly as it does today.Absent hosts don't vote; one refusal vetoes, because a user's panes and their children do not all run the same host and one refusal is one place
wmuxwould stop working. Verified, not assumed — same principle as the fix for #152.The interactive
wmuxfunction inwmux-powershell-integration.ps1already did the right thing with@args; what it could not reach is a PowerShell child of a pane (pwsh -NoProfile, which is how agents' shell tools run), and that inherits PATH but not the function. That is the population this fixes.Before / after, against a live instance
Tests:
tests/unit/powershell-shim.test.tspins the gate (enable when every installed host runs it, refuse if any blocks scripts, refuse when nothing could be probed, refuse on output that isn't the marker), plus an end-to-end block — skipped where no PowerShell exists — that runs your exact repro through PATH resolution and asserts both the argument arriving intact and the absence of a file named0.Thank you for this one. The
Access is denied.symptom you chased it down from is the kind of report that usually dies unexplained.capad-xyz commentedon Aug 13, 2026
0.50.0 landed on this machine today, so I re-ran the original repro against the shipped build. Confirmed fixed, on the machine the report came from:
Same argument, same host, and the
.cmdstill exhibits it, which is the useful half of the control: the difference is the shim, not the environment.One thing this did not exercise: the startup probe and the PATH ordering. I invoked both shims by absolute path, because neither shim dir is on this machine's persisted PATH. Taking the 0.50.0 update re-offered the install location, I moved it from per-user to All Users, and a PATH entry I had added by hand still pointed at the old root. Filed as #158, which is where that belongs. (I edited this comment twice getting that sequence straight; #158 has the account I actually verified.)
So read the above as "the shim does the right thing when it is reached", not as a verification of the gate.
For what it is worth, the gate itself looks right on this machine when probed the way you describe: with an inherited
Bypasscleared,powershell.exereportsCurrentUser = RemoteSigned andpwsh.exereportsLocalMachine = RemoteSigned, the shipped.ps1files carry noZone.Identifier, andwmux-shim-probe.ps1prints its marker in both hosts. Clearing the inherited policy mattered: probed without that, both hosts reportBypassand would have passed for the wrong reason, which is exactly the false positive you called out.docs(fork): record that the amirlehmam#154 PowerShell shim is inert u…
fix(codex): restore is fully inert when off, never hands PowerShell t…