Skip to content

fix(tui): resolve Windows shell commands via PowerShell#2279

Open
Hmbown wants to merge 1 commit into
mainfrom
fix/1779-windows-shell-dispatch
Open

fix(tui): resolve Windows shell commands via PowerShell#2279
Hmbown wants to merge 1 commit into
mainfrom
fix/1779-windows-shell-dispatch

Conversation

@Hmbown
Copy link
Copy Markdown
Owner

@Hmbown Hmbown commented May 27, 2026

Summary

  • add a small shared shell invocation resolver for shell-backed tools
  • on Windows, honor a known SHELL first, then prefer pwsh.exe / powershell.exe, with cmd /C kept as the UTF-8 fallback
  • route CommandSpec::shell, the offline eval shell step, and the prompt environment shell label through the resolver
  • keep the existing cmd raw-argument fallback covered so quoted command payloads still survive when PowerShell is unavailable

Fixes #1779.
Refs #1781.

Credit

Reported by @aboimpinto in #1779, with the broader ShellDispatcher v2 exploration in #1781. This PR intentionally harvests the narrow shell-resolution piece without taking the raw-mode/logging parts from that stale branch.

Testing

  • CARGO_TARGET_DIR=/Volumes/VIXinSSD/whalebro/codewhale/target cargo test -p codewhale-tui shell_invocation -- --nocapture
  • CARGO_TARGET_DIR=/Volumes/VIXinSSD/whalebro/codewhale/target cargo test -p codewhale-tui test_command_spec_shell -- --nocapture
  • RUSTFLAGS=-Dwarnings CARGO_TARGET_DIR=/Volumes/VIXinSSD/whalebro/codewhale/target cargo check -p codewhale-tui --all-features --locked
  • cargo fmt --all -- --check
  • git diff --check

Open in Devin Review

Copilot AI review requested due to automatic review settings May 27, 2026 12:30
Copy link
Copy Markdown

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmbown has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors platform shell resolution by introducing a new shell_invocation module. It extracts and unifies the logic for constructing shell commands on Windows and Unix platforms, adding support for detecting and utilizing pwsh.exe, powershell.exe, and POSIX-like shells on Windows based on environment variables and path probes. The reviewer provided valuable feedback regarding a compatibility issue when falling back to Windows PowerShell 5.1 (powershell.exe) due to its lack of support for pipeline chain operators (&&/||), recommending falling back to cmd.exe instead. Additionally, the reviewer pointed out a quote-preservation bug when cmd.exe is resolved via its full path, suggesting the reuse of shell_program_stem to robustly identify the shell program.

Comment on lines +131 to +137
if probe.pwsh_on_path {
return powershell_invocation("pwsh.exe", command);
}

if probe.powershell_on_path {
return powershell_invocation("powershell.exe", command);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

High Severity: Incompatible Default Fallback to Windows PowerShell 5.1 (powershell.exe)

Automatically falling back to powershell.exe when pwsh.exe (PowerShell Core 7+) is not found introduces major compatibility issues on default Windows installations:

  1. Lack of Pipeline Chain Operators (&& / ||):
    Windows PowerShell 5.1 (powershell.exe) does not support && and || operators (which were only introduced in PowerShell 7.0). Any LLM-generated or tool-generated multi-command sequence (e.g., cd dir && cargo build) will fail with a syntax error:
    The token '&&' is not a valid statement separator in this version.

  2. Variable Expansion and Quoting Quirks:
    PowerShell parses -Command arguments as PowerShell code, meaning environment variable references (like $VAR or %VAR%) and double quotes are parsed differently and often stripped or mangled before being passed to external executables.

Recommendation:
If pwsh.exe is not available on the PATH, it is much safer to fall back to cmd.exe (which is highly compatible with standard command syntax and handles &&/|| correctly). If a user explicitly wants to use powershell.exe, they can still do so by setting the SHELL environment variable, which is already handled by probe.shell at the beginning of this function.

Suggested change
if probe.pwsh_on_path {
return powershell_invocation("pwsh.exe", command);
}
if probe.powershell_on_path {
return powershell_invocation("powershell.exe", command);
}
if probe.pwsh_on_path {
return powershell_invocation("pwsh.exe", command);
}

Comment thread crates/tui/src/eval.rs
}

fn push_eval_shell_args(cmd: &mut Command, invocation: &EvalShellInvocation) {
fn push_eval_shell_args(cmd: &mut Command, invocation: &ShellInvocation) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

High Severity: Quote-preservation bug when cmd.exe is resolved via full path

In push_eval_shell_args (line 40), the code checks invocation.program.eq_ignore_ascii_case("cmd") to decide whether to use cmd.raw_arg for quote preservation.

However, if cmd.exe is resolved via its full path (e.g., C:\Windows\System32\cmd.exe from COMSPEC), eq_ignore_ascii_case("cmd") will return false. This bypasses the raw_arg quote-preservation logic, causing quotes in command payloads (like commit messages) to be mangled by MSVCRT escaping on Windows.

Since we made shell_program_stem pub(crate), we should use it here to robustly check if the program stem is "cmd".

Comment on lines +199 to +211
fn shell_program_stem(program: &str) -> Option<String> {
let normalized = program.trim().replace('\\', "/");
let filename = normalized.rsplit('/').next()?.trim();
let stem = filename
.strip_suffix(".exe")
.or_else(|| filename.strip_suffix(".EXE"))
.unwrap_or(filename);
if stem.is_empty() {
None
} else {
Some(stem.to_ascii_lowercase())
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Medium Severity: Simplify shell_program_stem and make it pub(crate)

We can simplify the case-insensitive .exe suffix stripping by converting the filename to lowercase first. This avoids chained .strip_suffix calls and handles mixed-case extensions (e.g., .Exe).

Additionally, making this function pub(crate) allows other modules (such as eval.rs in push_eval_shell_args) to reuse it to robustly check if the program is cmd.exe or cmd regardless of its full path.

pub(crate) fn shell_program_stem(program: &str) -> Option<String> {
    let normalized = program.trim().replace('\\', "/");
    let filename = normalized.rsplit('/').next()?.trim().to_ascii_lowercase();
    let stem = filename
        .strip_suffix(".exe")
        .unwrap_or(&filename);
    if stem.is_empty() {
        None
    } else {
        Some(stem.to_string())
    }
}

Comment thread crates/tui/src/eval.rs
args: Vec<String>,
raw_payload_on_windows: bool,
}
use crate::shell_invocation::{ShellInvocation, shell_invocation};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Medium Severity: Import shell_program_stem to robustly check for cmd

Import shell_program_stem so we can use it in push_eval_shell_args to robustly identify cmd.exe even when it is specified via a full path (e.g., from COMSPEC).

Suggested change
use crate::shell_invocation::{ShellInvocation, shell_invocation};
use crate::shell_invocation::{ShellInvocation, shell_invocation, shell_program_stem};

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment thread crates/tui/src/eval.rs
}

fn push_eval_shell_args(cmd: &mut Command, invocation: &EvalShellInvocation) {
fn push_eval_shell_args(cmd: &mut Command, invocation: &ShellInvocation) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 push_eval_shell_args fails to use raw_arg when cmd.exe program is a full path

The push_eval_shell_args function at crates/tui/src/eval.rs:40 checks invocation.program.eq_ignore_ascii_case("cmd") to decide whether to use raw_arg for cmd.exe. Before this PR, the old EvalShellInvocation always used program: "cmd", so this check always passed. After the refactoring to use the shared ShellInvocation, the program field can now be a full path like C:\Windows\System32\cmd.exe (from COMSPEC) or cmd.exe, causing the check to fail. When it fails, the function falls through to cmd.args(...) instead of cmd.raw_arg(...), which breaks quoting for commands with embedded quotes on Windows — the exact regression that issue #1691 fixed.

The correct approach is already demonstrated in crates/tui/src/tools/shell.rs:198-202, which extracts the file stem from the program path using Path::file_stem() before comparing.

Prompt for agents
The function push_eval_shell_args in crates/tui/src/eval.rs uses invocation.program.eq_ignore_ascii_case("cmd") on line 40 to detect cmd.exe and route through raw_arg. This worked when program was always "cmd" (old EvalShellInvocation), but now that ShellInvocation is used, program can be a full path like C:\Windows\System32\cmd.exe.

The fix should match the approach used in crates/tui/src/tools/shell.rs push_shell_args (lines 198-202), which uses std::path::Path::new(program).file_stem() to extract just the stem before comparing. Replace the eq_ignore_ascii_case("cmd") check on line 40 with a file_stem-based check, e.g.:

let is_cmd = std::path::Path::new(&invocation.program)
    .file_stem()
    .and_then(|s| s.to_str())
    .map(|s| s.eq_ignore_ascii_case("cmd"))
    .unwrap_or(false);
if invocation.raw_payload_on_windows
    && is_cmd
    && invocation.args.len() == 2
    && invocation.args[0].eq_ignore_ascii_case("/C")

This is important because COMSPEC is almost always set to C:\Windows\System32\cmd.exe on Windows, so the current code would fail to use raw_arg in the vast majority of cases.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@Hmbown
Copy link
Copy Markdown
Owner Author

Hmbown commented May 27, 2026

Independent review (Opus, post-greptile/gemini, simulated merge in /tmp/cw-rev-2279, cargo check --all-targets clean):

  1. prompts.rs:146 leaks into the model prompt. shell_invocation("").program is rendered into the ## Environment block verbatim. Two issues: (a) with no SHELL/COMSPEC/PATH hit (minimal CI, container), the final fallback at shell_invocation.rs:135 returns the literal string "cmd" — not a shell path, just cmd. (b) When pwsh resolves via SHELL, you emit C:\Program Files\PowerShell\7\pwsh.exe instead of pwsh — noisy. Strip via shell_program_stem() before formatting.
  2. PowerShell args missing -NonInteractive. powershell_invocation() only sets -NoProfile -Command. A payload that triggers Get-Credential, module install prompts, or Read-Host will hang the subprocess until the timeout fires. Add -NonInteractive; consider -InputFormat None too.
  3. Confirming Gemini's WinPS 5.1 && concern with repo evidence. Agent-generated shell calls in this codebase emit chained cmd1 && cmd2 (git workflows, build steps). On powershell.exe 5.1 (bundled in every Win10/11) -Command "a && b" errors with '&&' is not a valid statement separator. The pwsh-then-powershell-then-cmd order means a 5.1-only box silently breaks every chained command. Prefer cmd.exe over WinPS 5.1, or rewrite chains to ;/-and when emitting to 5.1.
  4. Test gap. No coverage for: --% (PS stop-parsing), embedded backtick, newline-in-arg, or COMSPEC pointing at a non-cmd binary (currently falls silently to the literal "cmd" fallback — should probe or error).
  5. v0.8.48 (refactor: consolidate workspace crates — 14→11 (delete tui-core, merge hooks+agent) #2256) merge. Overlap on main.rs (mod decl L65 here vs Xiaomi provider edits L1889+) and prompts.rs (4 lines at L142-149). Non-overlapping ranges — trivially mergeable, no rebase blocker.

Copy link
Copy Markdown
Contributor

I cannot make direct changes to this branch from my current environment, so I pushed the remaining fixes to a follow-up branch and opened a separate draft PR: #2284.

That follow-up includes the shell stem cleanup in the prompt environment block, -NonInteractive for PowerShell, and the existing cmd.exe full-path raw-argument fix.

Paulo Aboim Pinto

1 similar comment
Copy link
Copy Markdown
Contributor

I cannot make direct changes to this branch from my current environment, so I pushed the remaining fixes to a follow-up branch and opened a separate draft PR: #2284.

That follow-up includes the shell stem cleanup in the prompt environment block, -NonInteractive for PowerShell, and the existing cmd.exe full-path raw-argument fix.

Paulo Aboim Pinto

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Windows: shell dispatcher hardcodes cmd.exe, ignoring user's actual shell (PowerShell, pwsh, WSL)

3 participants