Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion crates/buzz-dev-mcp/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ pub async fn run(
};
let shell_arg = shell_flag(&bash);
let mut cmd = Command::new(&bash);
cmd.arg(shell_arg).arg(&p.command);
cmd.arg(shell_arg)
.arg(shell_command_string(&bash, &p.command));
cmd.current_dir(&workdir);
cmd.env("PATH", &state.shim.path_env);
// NOSTR_PRIVATE_KEY is already removed from this process's env (shim.rs).
Expand Down Expand Up @@ -346,6 +347,30 @@ fn shell_flag(shell: &Path) -> &'static str {
}
}

/// Pins BOM-less UTF-8 on a PowerShell session's pipes to a native process.
const POWERSHELL_UTF8_PREAMBLE: &str =
"$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new(); ";

/// Build the command string handed to the shell.
///
/// Windows PowerShell 5.1 encodes what it writes to a native process with the
/// legacy console codepage, substituting `?` for every character that codepage
/// cannot represent. Text piped into `buzz messages send --content -` is
/// corrupted before the event is built, and the command still reports success,
/// so the damage is permanent and silent. Pin UTF-8 for the session rather than
/// relying on each caller to set it (#6527).
fn shell_command_string(shell: &Path, command: &str) -> String {
match shell
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_ascii_lowercase())
.as_deref()
{
Some("powershell" | "pwsh") => format!("{POWERSHELL_UTF8_PREAMBLE}{command}"),
_ => command.to_string(),
}
}

/// Extract a short display name from a resolved shell path (e.g. `pwsh.exe` → `"pwsh"`).
fn shell_name_from_path(p: &Path) -> String {
p.file_stem()
Expand Down Expand Up @@ -993,6 +1018,33 @@ mod tests {
SharedState::new(cwd.to_path_buf(), shim).expect("state new")
}

/// Windows PowerShell 5.1 encodes a native process's pipes with the legacy
/// console codepage, which substitutes `?` for anything it cannot represent
/// — the command still succeeds and the corrupted text is signed as-is.
#[test]
fn powershell_command_pins_utf8_before_the_callers_command() {
let unicode = "buzz messages send --content 'em dash — café 測試 🐝'";
for shell in ["powershell.exe", "pwsh.exe", "pwsh"] {
let built = shell_command_string(Path::new(shell), unicode);
assert!(
built.starts_with(POWERSHELL_UTF8_PREAMBLE),
"{shell} should pin UTF-8 first, got: {built}"
);
assert!(built.ends_with(unicode), "{shell} must keep the command");
}
}

/// Shells without the codepage boundary must be handed the command verbatim.
#[test]
fn other_shells_receive_the_command_unchanged() {
for shell in ["bash", "/bin/zsh", "sh", "cmd.exe"] {
assert_eq!(
shell_command_string(Path::new(shell), "echo hello"),
"echo hello"
);
}
}

/// Pull the JSON body out of a CallToolResult so tests can assert on fields.
fn body(r: rmcp::model::CallToolResult) -> Value {
let text = match r.content.first().and_then(|c| c.as_text()) {
Expand Down