mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
## Summary Goose 1.52 starts MCP connections with `server/discover`. `buzz-dev-mcp` rejects that request with `ExpectedInitializeRequest`, preventing Goose from loading its Buzz tools. Upgrade the tool server to rmcp 3.4.1 and adopt the renamed SDK types. Subprocess tests exercise discovery and legacy initialization, tool listing, file reads/edits, invalid arguments, connection survival, and EOF shutdown. Include the server in both Rust unit-test runners and make the cancellation regression approve its shell call before testing cancellation. The native agent retains its independent rmcp 1.x client. Cargo also unifies the existing 3.x dependency used by the mesh smoke harness. ## Validation - Normal `git push` passed all hooks, including Rust tests, Tauri checks, and repository policy checks. - `just ci` passed on the final stack: repository checks, Rust and Tauri tests/lint, desktop tests/build, web build, and mobile checks/tests. - MCP: 78 existing tests and two subprocess lifecycle tests passed; the discovery regression fails with the old SDK. - All 54 native-agent regressions passed on the final stack, including cancellation of a real MCP shell process. - Previously exercised unmodified Goose 1.52 and 1.47 with Sandpit and the real tool server. Allowed workspace operations and permitted network traffic succeeded; disallowed file and network operations were denied. - Live staging desktop check with Goose 1.52: the reply appeared in Buzz, an allowed request returned HTTP 200, and a disallowed request returned CONNECT 403. ## Stack 1. **#8042 → `main`**: deterministic handoff regression. 2. **This PR → #8042**: Goose MCP discovery compatibility. Merge the lower PR first, then retarget this PR to `main`. This diff contains only the compatibility change and its tests; the relay build repair is already on `main` in #8036. --------- Signed-off-by: Alex Rosenzweig <arosenzweig@squareup.com>
1509 lines
56 KiB
Rust
1509 lines
56 KiB
Rust
use crate::shim::Shim;
|
|
use rmcp::model::{CallToolResult, ContentBlock};
|
|
use rmcp::ErrorData;
|
|
use schemars::JsonSchema;
|
|
use serde::Deserialize;
|
|
use std::collections::VecDeque;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Stdio;
|
|
use std::sync::Mutex;
|
|
use std::time::{Duration, Instant};
|
|
use tempfile::TempDir;
|
|
use tokio::io::{AsyncRead, AsyncReadExt};
|
|
use tokio::process::Command;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
const DEFAULT_TIMEOUT_MS: u64 = 120_000;
|
|
const MAX_TIMEOUT_MS: u64 = 1_200_000;
|
|
const MAX_COMMAND_BYTES: usize = 1_000_000;
|
|
const CAPTURE_CAP: usize = 10 * 1024 * 1024;
|
|
const MAX_BYTES: usize = 50 * 1024;
|
|
const MAX_LINES: usize = 2000;
|
|
const TAIL_BYTES: usize = 8 * 1024;
|
|
const ARTIFACT_RING_SIZE: usize = 8;
|
|
const READ_CHUNK: usize = 16 * 1024;
|
|
|
|
pub struct SharedState {
|
|
pub cwd: PathBuf,
|
|
pub shim: Shim,
|
|
pub session_dir: TempDir,
|
|
pub bootstrap_instructions: String,
|
|
/// The shell resolved at construction: `Ok((path, display_name))` when a shell
|
|
/// is available, `Err(msg)` when none was found. Stored once so both the
|
|
/// bootstrap hint and every `run()` call read the SAME resolution — no drift.
|
|
pub resolved_shell: Result<(PathBuf, String), String>,
|
|
pub artifacts: Mutex<VecDeque<PathBuf>>,
|
|
next_call_id: Mutex<u64>,
|
|
}
|
|
|
|
impl SharedState {
|
|
pub fn new(cwd: PathBuf, shim: Shim) -> std::io::Result<Self> {
|
|
let session_dir = tempfile::Builder::new()
|
|
.prefix("buzz-dev-mcp-session-")
|
|
.tempdir()?;
|
|
// Resolve the shell ONCE using the same PATH the spawn will use.
|
|
// Both the bootstrap dialect hint and every run() call read this result,
|
|
// so they can never disagree. A failed resolution is stored as Err and
|
|
// surfaces as an actionable error on the first tool call.
|
|
let resolved_shell = resolve_bash(&shim.path_env);
|
|
let shell_hint = match &resolved_shell {
|
|
Ok((_, name)) => name.as_str(),
|
|
Err(_) => "bash",
|
|
};
|
|
let bootstrap_instructions = build_bootstrap(&cwd, shell_hint);
|
|
Ok(Self {
|
|
cwd,
|
|
shim,
|
|
session_dir,
|
|
bootstrap_instructions,
|
|
resolved_shell,
|
|
artifacts: Mutex::new(VecDeque::with_capacity(ARTIFACT_RING_SIZE)),
|
|
next_call_id: Mutex::new(0),
|
|
})
|
|
}
|
|
|
|
fn next_id(&self) -> u64 {
|
|
let mut g = match self.next_call_id.lock() {
|
|
Ok(g) => g,
|
|
Err(p) => p.into_inner(),
|
|
};
|
|
*g += 1;
|
|
*g
|
|
}
|
|
}
|
|
|
|
fn build_bootstrap(cwd: &Path, shell_hint: &str) -> String {
|
|
let stack = detect_stack(cwd);
|
|
let buzz_hint =
|
|
if std::env::var("BUZZ_RELAY_URL").is_ok() && std::env::var("BUZZ_PRIVATE_KEY").is_ok() {
|
|
"\nBuzz relay configured. Run `buzz --help` to see available commands.\n"
|
|
} else {
|
|
""
|
|
};
|
|
format!(
|
|
"Working directory: {}\n\
|
|
Detected stack: {}\n\
|
|
Shell: {shell_hint} (set BUZZ_SHELL to override) — write command strings in that shell's syntax.\n\
|
|
Pass `workdir` per call rather than `cd`.\n\
|
|
{buzz_hint}",
|
|
cwd.display(),
|
|
stack,
|
|
)
|
|
}
|
|
|
|
fn detect_stack(cwd: &Path) -> String {
|
|
let markers = [
|
|
("Cargo.toml", "rust (cargo)"),
|
|
("package.json", "node"),
|
|
("go.mod", "go"),
|
|
("pyproject.toml", "python (pyproject)"),
|
|
("requirements.txt", "python"),
|
|
("Gemfile", "ruby"),
|
|
("pom.xml", "java (maven)"),
|
|
("build.gradle", "java (gradle)"),
|
|
("build.gradle.kts", "kotlin (gradle)"),
|
|
];
|
|
let mut found: Vec<&str> = markers
|
|
.iter()
|
|
.filter(|(f, _)| cwd.join(f).exists())
|
|
.map(|(_, name)| *name)
|
|
.collect();
|
|
if found.is_empty() {
|
|
"unknown".into()
|
|
} else {
|
|
found.sort();
|
|
found.join(", ")
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, JsonSchema)]
|
|
pub struct ShellParams {
|
|
pub command: String,
|
|
#[serde(default)]
|
|
pub workdir: Option<String>,
|
|
/// Defaults to 120000 ms (2 min) if omitted; capped at 1,200,000 ms (20 min).
|
|
/// For long-running commands (git push with hooks, cargo build, test suites), use 300000+.
|
|
#[serde(default)]
|
|
pub timeout_ms: Option<u64>,
|
|
}
|
|
|
|
fn effective_timeout_ms(requested: Option<u64>) -> u64 {
|
|
requested.unwrap_or(DEFAULT_TIMEOUT_MS).min(MAX_TIMEOUT_MS)
|
|
}
|
|
|
|
pub async fn run(
|
|
state: &SharedState,
|
|
p: ShellParams,
|
|
ct: CancellationToken,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
if p.command.len() > MAX_COMMAND_BYTES {
|
|
return Err(ErrorData::invalid_params(
|
|
format!("command exceeds {MAX_COMMAND_BYTES} byte limit"),
|
|
None,
|
|
));
|
|
}
|
|
let timeout_ms = effective_timeout_ms(p.timeout_ms);
|
|
let workdir: PathBuf = p
|
|
.workdir
|
|
.as_deref()
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| state.cwd.clone());
|
|
|
|
if !workdir.is_dir() {
|
|
return Err(ErrorData::invalid_params(
|
|
format!(
|
|
"workdir does not exist or is not a directory: {}",
|
|
workdir.display()
|
|
),
|
|
None,
|
|
));
|
|
}
|
|
|
|
let bash = match &state.resolved_shell {
|
|
Ok((path, _)) => path.clone(),
|
|
Err(msg) => return Ok(CallToolResult::error(vec![ContentBlock::text(msg.clone())])),
|
|
};
|
|
let shell_arg = shell_flag(&bash);
|
|
let mut cmd = Command::new(&bash);
|
|
cmd.arg(shell_arg).arg(&p.command);
|
|
cmd.current_dir(&workdir);
|
|
cmd.env("PATH", &state.shim.path_env);
|
|
cmd.stdin(Stdio::null());
|
|
cmd.stdout(Stdio::piped());
|
|
cmd.stderr(Stdio::piped());
|
|
cmd.kill_on_drop(true);
|
|
set_process_group(&mut cmd);
|
|
crate::configure_no_window_async(&mut cmd);
|
|
|
|
let started = Instant::now();
|
|
let mut child = match cmd.spawn() {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
return Ok(CallToolResult::error(vec![ContentBlock::text(format!(
|
|
"failed to spawn shell: {e}"
|
|
))]));
|
|
}
|
|
};
|
|
|
|
let pid = child.id();
|
|
|
|
// KillGroup ties the spawned bash and all its descendants to a single kill
|
|
// primitive (Unix process group / Windows Job Object). Built from the live
|
|
// child so the Windows job can take the process handle, which only exists
|
|
// after spawn. Held for the whole run; its Drop is the last-resort reaper.
|
|
let mut kill_group = KillGroup::new(&child, pid);
|
|
|
|
let stdout_pipe = child.stdout.take();
|
|
let stderr_pipe = child.stderr.take();
|
|
|
|
let mut stdout_handle = tokio::spawn(async move {
|
|
match stdout_pipe {
|
|
Some(p) => read_capped(p).await,
|
|
None => CapturedStream::default(),
|
|
}
|
|
});
|
|
let mut stderr_handle = tokio::spawn(async move {
|
|
match stderr_pipe {
|
|
Some(p) => read_capped(p).await,
|
|
None => CapturedStream::default(),
|
|
}
|
|
});
|
|
|
|
let timeout_dur = Duration::from_millis(timeout_ms);
|
|
let mut notes: Vec<String> = Vec::new();
|
|
let (status, timed_out) = tokio::select! {
|
|
biased;
|
|
_ = ct.cancelled() => {
|
|
// Kill process group, reap child, abort reader tasks.
|
|
kill_group.kill_immediate();
|
|
// Bounded reap so we don't leak zombies. If reap times out,
|
|
// KillGroup drop will kill again as a last resort.
|
|
match tokio::time::timeout(Duration::from_secs(1), child.wait()).await {
|
|
Ok(Ok(_)) => { kill_group.disarm(); } // reaped; disarm guard
|
|
Ok(Err(e)) => {
|
|
tracing::debug!("cancel: child wait error: {e}");
|
|
// Leave kill_group armed for drop-kill.
|
|
}
|
|
Err(_) => {
|
|
tracing::debug!("cancel: child reap timed out; guard will kill on drop");
|
|
}
|
|
}
|
|
stdout_handle.abort();
|
|
stderr_handle.abort();
|
|
return Ok(CallToolResult::error(vec![ContentBlock::text("cancelled")]));
|
|
}
|
|
r = tokio::time::timeout(timeout_dur, child.wait()) => match r {
|
|
Ok(Ok(s)) => (Some(s), false),
|
|
Ok(Err(err)) => {
|
|
notes.push(format!("child wait failed: {err}"));
|
|
(None, false)
|
|
}
|
|
Err(_) => {
|
|
// Kill process group — this closes the pipes, causing reads to EOF.
|
|
kill_group.kill_graceful().await;
|
|
// Reap the child so it doesn't become a zombie.
|
|
let deadline = Instant::now() + Duration::from_secs(2);
|
|
loop {
|
|
match child.try_wait() {
|
|
Ok(Some(_)) => break,
|
|
Ok(None) if Instant::now() >= deadline => {
|
|
if let Err(e) = child.start_kill() {
|
|
notes.push(format!("force-kill failed: {e}"));
|
|
}
|
|
if let Err(e) = child.wait().await {
|
|
notes.push(format!("post-kill wait: {e}"));
|
|
}
|
|
break;
|
|
}
|
|
Ok(None) => {
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
}
|
|
Err(err) => {
|
|
notes.push(format!("try_wait failed: {err}"));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
(None, true)
|
|
}
|
|
}
|
|
};
|
|
|
|
if !timed_out {
|
|
kill_group.kill_graceful().await;
|
|
}
|
|
|
|
let stdout_cap = match tokio::time::timeout(Duration::from_secs(5), &mut stdout_handle).await {
|
|
Ok(Ok(cap)) => cap,
|
|
_ => {
|
|
stdout_handle.abort();
|
|
notes.push("stdout reader did not complete".into());
|
|
CapturedStream::default()
|
|
}
|
|
};
|
|
let stderr_cap = match tokio::time::timeout(Duration::from_secs(5), &mut stderr_handle).await {
|
|
Ok(Ok(cap)) => cap,
|
|
_ => {
|
|
stderr_handle.abort();
|
|
notes.push("stderr reader did not complete".into());
|
|
CapturedStream::default()
|
|
}
|
|
};
|
|
|
|
let duration_ms = started.elapsed().as_millis() as u64;
|
|
let exit_code = status
|
|
.as_ref()
|
|
.and_then(|s| s.code())
|
|
.unwrap_or(if timed_out { 124 } else { -1 });
|
|
|
|
let id = state.next_id();
|
|
let (stdout_text, stdout_truncated, stdout_artifact) =
|
|
finalize_stream(state, id, "stdout", stdout_cap, &mut notes);
|
|
let (stderr_text, stderr_truncated, stderr_artifact) =
|
|
finalize_stream(state, id, "stderr", stderr_cap, &mut notes);
|
|
|
|
let body = serde_json::json!({
|
|
"exit_code": exit_code,
|
|
"stdout": stdout_text,
|
|
"stderr": stderr_text,
|
|
"timed_out": timed_out,
|
|
"duration_ms": duration_ms,
|
|
"stdout_truncated": stdout_truncated,
|
|
"stderr_truncated": stderr_truncated,
|
|
"stdout_artifact": stdout_artifact,
|
|
"stderr_artifact": stderr_artifact,
|
|
"notes": notes,
|
|
});
|
|
let text = serde_json::to_string_pretty(&body).unwrap_or_else(|_| "{}".into());
|
|
kill_group.disarm();
|
|
Ok(CallToolResult::success(vec![ContentBlock::text(text)]))
|
|
}
|
|
|
|
/// The flag used to pass a command string to the shell.
|
|
///
|
|
/// bash/zsh/sh: `-c`
|
|
/// cmd.exe: `/C`
|
|
/// powershell/pwsh: `-Command`
|
|
///
|
|
/// The resolver supports `BUZZ_SHELL=cmd`/`pwsh` (explicit operator overrides
|
|
/// resolve without the System32 exclusion, so these shells work). The dispatch
|
|
/// here ensures each shell receives the correct flag regardless of which one
|
|
/// was resolved.
|
|
fn shell_flag(shell: &Path) -> &'static str {
|
|
match shell
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.map(|s| s.to_ascii_lowercase())
|
|
.as_deref()
|
|
{
|
|
Some("cmd") => "/C",
|
|
Some("powershell" | "pwsh") => "-Command",
|
|
_ => "-c",
|
|
}
|
|
}
|
|
|
|
/// 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()
|
|
.and_then(|s| s.to_str())
|
|
.map(|s| s.to_ascii_lowercase())
|
|
.unwrap_or_else(|| "bash".to_string())
|
|
}
|
|
|
|
/// Resolve the shell to spawn. On Unix, bash on PATH is correct and was never
|
|
/// broken, so the resolver only needs to honor BUZZ_SHELL. The probe logic and
|
|
/// System32 exclusion are Windows-only.
|
|
///
|
|
/// Returns `(resolved_path, display_name)`. The display name is derived from the
|
|
/// resolved path so the caller can use it for diagnostics without a second lookup.
|
|
#[cfg(not(windows))]
|
|
fn resolve_bash(_path_env: &str) -> Result<(PathBuf, String), String> {
|
|
// Honor BUZZ_SHELL on Unix so power users can opt into zsh or another shell.
|
|
if let Some(raw) = std::env::var_os("BUZZ_SHELL") {
|
|
let p = PathBuf::from(&raw);
|
|
// Absolute / rooted path: must exist as a file.
|
|
if p.components().count() > 1 || p.has_root() {
|
|
if p.is_file() {
|
|
let name = shell_name_from_path(&p);
|
|
return Ok((p, name));
|
|
}
|
|
// Non-existent path: fall through to bash.
|
|
} else {
|
|
// Bare command name: scan the process PATH directly.
|
|
let path_var = std::env::var_os("PATH").unwrap_or_default();
|
|
for dir in std::env::split_paths(&path_var) {
|
|
let candidate = dir.join(&p);
|
|
if candidate.is_file() {
|
|
let name = shell_name_from_path(&candidate);
|
|
return Ok((candidate, name));
|
|
}
|
|
}
|
|
// Not found: fall through to bash.
|
|
}
|
|
}
|
|
Ok((PathBuf::from("bash"), "bash".to_string()))
|
|
}
|
|
|
|
/// Windows bash resolution. Probe order (first hit wins):
|
|
/// 1. `BUZZ_SHELL` env override — explicit operator choice, any shell (cmd,
|
|
/// PowerShell, bash, etc.). Bare command names are resolved through PATH
|
|
/// WITHOUT the System32 exclusion — the operator explicitly chose this shell,
|
|
/// and cmd.exe/powershell.exe live in System32 legitimately.
|
|
/// 2. `GIT_BASH` env override — legacy escape hatch (kept for back-compat).
|
|
/// 3. `bash.exe` on PATH, excluding System32 so we never resolve WSL's launcher.
|
|
/// 4. `git.exe` on PATH → its sibling `..\\bin\\bash.exe`. Git for Windows's
|
|
/// recommended "Git from the command line" option adds `Git\\cmd` to PATH,
|
|
/// not `Git\\bin`, so this is the normal post-install route.
|
|
/// 5. Standard `ProgramFiles`, `ProgramFiles(x86)`, and `LocalAppData` paths
|
|
/// when the child inherited their parent environment.
|
|
/// 6. Git for Windows's machine then user registry `InstallPath`.
|
|
///
|
|
/// Returns `(resolved_path, display_name)`. The display name is derived from the
|
|
/// resolved path, guaranteeing the dialect hint and the spawned shell agree.
|
|
///
|
|
/// No bash found -> actionable error pointing at the prerequisite.
|
|
#[cfg(windows)]
|
|
fn resolve_bash(path_env: &str) -> Result<(PathBuf, String), String> {
|
|
if let Some(raw) = std::env::var_os("BUZZ_SHELL") {
|
|
let p = PathBuf::from(&raw);
|
|
if p.components().count() > 1 || p.has_root() {
|
|
if p.is_file() {
|
|
let name = shell_name_from_path(&p);
|
|
return Ok((p, name));
|
|
}
|
|
} else if let Some(found) = scan_path_for_command(&p, path_env, None) {
|
|
let name = shell_name_from_path(&found);
|
|
return Ok((found, name));
|
|
}
|
|
}
|
|
|
|
if let Some(p) = std::env::var_os("GIT_BASH").map(PathBuf::from) {
|
|
if p.is_file() {
|
|
let name = shell_name_from_path(&p);
|
|
return Ok((p, name));
|
|
}
|
|
}
|
|
|
|
let system_root = std::env::var_os("SystemRoot").map(PathBuf::from);
|
|
if let Some(p) = scan_path_for_bash(path_env, system_root.as_deref()) {
|
|
return Ok((p, "bash".to_string()));
|
|
}
|
|
|
|
if let Some(git) = scan_path_for_command(Path::new("git.exe"), path_env, None) {
|
|
if let Some(bash) = bash_from_git(&git) {
|
|
return Ok((bash, "bash".to_string()));
|
|
}
|
|
}
|
|
|
|
if let Some(bash) = git_bash_from_standard_paths() {
|
|
return Ok((bash, "bash".to_string()));
|
|
}
|
|
|
|
if let Some(bash) = git_bash_from_registry() {
|
|
return Ok((bash, "bash".to_string()));
|
|
}
|
|
|
|
Err(
|
|
"Git for Windows (Git Bash) is required but was not found. Checked \\
|
|
BUZZ_SHELL, GIT_BASH, bash.exe and git.exe on PATH, the standard Git install locations, \\
|
|
and HKLM/HKCU\\\\SOFTWARE\\\\GitForWindows. Git's \"Cmd\" PATH option adds \\
|
|
Git\\\\cmd\\\\git.exe but not Git\\\\bin\\\\bash.exe; Buzz normally derives Git Bash from that git.exe. \\
|
|
Install it from https://git-scm.com/download/win and select \"Git from the command line \\
|
|
and also from 3rd-party software\", then relaunch Buzz. You can also set \\
|
|
BUZZ_SHELL to a shell executable."
|
|
.into(),
|
|
)
|
|
}
|
|
|
|
/// Git for Windows puts `git.exe` in `<install>\\cmd`; the MSYS bash binary is
|
|
/// its stable sibling at `<install>\\bin\\bash.exe`.
|
|
#[cfg(windows)]
|
|
fn bash_from_git(git: &Path) -> Option<PathBuf> {
|
|
let install_root = git.parent()?.parent()?;
|
|
let bash = install_root.join("bin").join("bash.exe");
|
|
bash.is_file().then_some(bash)
|
|
}
|
|
|
|
/// Probe machine and per-user Git for Windows registry keys after the standard
|
|
/// install-location fallback has been exhausted.
|
|
#[cfg(windows)]
|
|
#[allow(unsafe_code)]
|
|
fn git_bash_from_registry() -> Option<PathBuf> {
|
|
use std::ffi::OsString;
|
|
use std::os::windows::ffi::OsStringExt;
|
|
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, ERROR_SUCCESS};
|
|
use windows_sys::Win32::System::Registry::{
|
|
RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE,
|
|
KEY_READ,
|
|
};
|
|
|
|
const KEY: &str = "SOFTWARE\\GitForWindows";
|
|
const VALUE: &str = "InstallPath";
|
|
let key: Vec<u16> = KEY.encode_utf16().chain(Some(0)).collect();
|
|
let value: Vec<u16> = VALUE.encode_utf16().chain(Some(0)).collect();
|
|
|
|
// SAFETY: Inputs are null-terminated UTF-16 for the duration of each call,
|
|
// and every successfully opened handle is closed before trying the next hive.
|
|
unsafe {
|
|
for hive in [HKEY_LOCAL_MACHINE, HKEY_CURRENT_USER] {
|
|
let mut handle = std::ptr::null_mut();
|
|
if RegOpenKeyExW(hive, key.as_ptr(), 0, KEY_READ, &mut handle) != ERROR_SUCCESS {
|
|
continue;
|
|
}
|
|
|
|
let mut byte_len = 0;
|
|
let status = RegQueryValueExW(
|
|
handle,
|
|
value.as_ptr(),
|
|
std::ptr::null(),
|
|
std::ptr::null_mut(),
|
|
std::ptr::null_mut(),
|
|
&mut byte_len,
|
|
);
|
|
if (status != ERROR_SUCCESS && status != ERROR_MORE_DATA) || byte_len == 0 {
|
|
RegCloseKey(handle);
|
|
continue;
|
|
}
|
|
|
|
let mut data = vec![0u16; (byte_len as usize).div_ceil(2)];
|
|
let status = RegQueryValueExW(
|
|
handle,
|
|
value.as_ptr(),
|
|
std::ptr::null(),
|
|
std::ptr::null_mut(),
|
|
data.as_mut_ptr().cast(),
|
|
&mut byte_len,
|
|
);
|
|
RegCloseKey(handle);
|
|
if status != ERROR_SUCCESS {
|
|
continue;
|
|
}
|
|
|
|
while data.last() == Some(&0) {
|
|
data.pop();
|
|
}
|
|
let bash = PathBuf::from(OsString::from_wide(&data))
|
|
.join("bin")
|
|
.join("bash.exe");
|
|
if bash.is_file() {
|
|
return Some(bash);
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn git_bash_from_standard_paths() -> Option<PathBuf> {
|
|
git_bash_from_standard_path_bases([
|
|
std::env::var_os("ProgramFiles").map(PathBuf::from),
|
|
std::env::var_os("ProgramFiles(x86)").map(PathBuf::from),
|
|
std::env::var_os("LocalAppData").map(PathBuf::from),
|
|
])
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn git_bash_from_standard_path_bases(
|
|
[program_files, program_files_x86, local_app_data]: [Option<PathBuf>; 3],
|
|
) -> Option<PathBuf> {
|
|
[
|
|
program_files.map(|base| base.join("Git")),
|
|
program_files_x86.map(|base| base.join("Git")),
|
|
local_app_data.map(|base| base.join("Programs").join("Git")),
|
|
]
|
|
.into_iter()
|
|
.flatten()
|
|
.map(|install_root| install_root.join("bin").join("bash.exe"))
|
|
.find(|bash| bash.is_file())
|
|
}
|
|
|
|
/// True if `path` is inside the Windows app-execution-alias directory
|
|
/// (`%LOCALAPPDATA%\Microsoft\WindowsApps`). Paths in that directory are WSL
|
|
/// stub launchers, not real executables — running them spawns `wsl.exe` /
|
|
/// `wslhost.exe` / `conhost.exe` trees rather than the intended shell.
|
|
///
|
|
/// The check is purely path-structural (component-wise, case-insensitive) so it
|
|
/// compiles and is testable on any host. It matches the path component named
|
|
/// `Microsoft` immediately followed by `WindowsApps`, so a sibling directory
|
|
/// named `MicrosoftWindowsApps` does not match.
|
|
#[cfg(any(windows, test))]
|
|
fn is_windows_apps_alias(path: &Path) -> bool {
|
|
let mut components = path.components().peekable();
|
|
while components.peek().is_some() {
|
|
let mut it = components.clone();
|
|
if it.next().is_some_and(|c| {
|
|
c.as_os_str()
|
|
.to_string_lossy()
|
|
.eq_ignore_ascii_case("Microsoft")
|
|
}) && it.next().is_some_and(|c| {
|
|
c.as_os_str()
|
|
.to_string_lossy()
|
|
.eq_ignore_ascii_case("WindowsApps")
|
|
}) {
|
|
return true;
|
|
}
|
|
components.next();
|
|
}
|
|
false
|
|
}
|
|
|
|
/// True if `dir` is `root` or lives under it, comparing path components
|
|
/// case-INsensitively. Windows paths are case-insensitive, but `Path::starts_with`
|
|
/// compares components case-sensitively on every platform — so a PATH entry spelled
|
|
/// `C:\WINDOWS\System32` would slip past a `%SystemRoot%`=`C:\Windows` prefix test
|
|
/// and let WSL's `System32\bash.exe` be resolved, reintroducing the `0x8007072c`
|
|
/// spawn failure. Component-wise comparison (not a lowercased substring match) avoids
|
|
/// a false hit on a sibling like `C:\Windows2`.
|
|
#[cfg(windows)]
|
|
fn is_under_dir(dir: &Path, root: &Path) -> bool {
|
|
let mut dir_components = dir.components();
|
|
for root_component in root.components() {
|
|
match dir_components.next() {
|
|
Some(d)
|
|
if d.as_os_str()
|
|
.eq_ignore_ascii_case(root_component.as_os_str()) => {}
|
|
_ => return false,
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Scan the child's PATH for `bash.exe`, skipping the Windows system directory
|
|
/// (`system_root`, normally `%SystemRoot%`) and the Windows app-execution-alias
|
|
/// directory (`%LOCALAPPDATA%\Microsoft\WindowsApps`) so we never resolve WSL's
|
|
/// `System32\bash.exe` or the `WindowsApps\bash.exe` stub launcher.
|
|
///
|
|
/// Skipping happens during iteration so scanning continues to the next PATH entry
|
|
/// when an alias is encountered — alias-first/real-bash-second selects the real one.
|
|
/// PATH is parsed with `std::env::split_paths` (never a hand-split on `;`) so it
|
|
/// matches exactly what the spawned child would see.
|
|
#[cfg(windows)]
|
|
fn scan_path_for_bash(path_env: &str, system_root: Option<&Path>) -> Option<PathBuf> {
|
|
for dir in std::env::split_paths(path_env) {
|
|
if let Some(root) = system_root {
|
|
if is_under_dir(&dir, root) {
|
|
continue;
|
|
}
|
|
}
|
|
let candidate = dir.join("bash.exe");
|
|
if candidate.is_file() && !is_windows_apps_alias(&candidate) {
|
|
return Some(candidate);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Scan `path_env` for `name` (or `name.exe` on Windows if `name` has no
|
|
/// extension), skipping any directory under `system_root` to avoid resolving
|
|
/// WSL helpers. Returns the first absolute path found.
|
|
#[cfg(windows)]
|
|
fn scan_path_for_command(
|
|
name: &Path,
|
|
path_env: &str,
|
|
system_root: Option<&Path>,
|
|
) -> Option<PathBuf> {
|
|
let needs_exe = name.extension().is_none();
|
|
for dir in std::env::split_paths(path_env) {
|
|
if let Some(root) = system_root {
|
|
if is_under_dir(&dir, root) {
|
|
continue;
|
|
}
|
|
}
|
|
// Try as-is first.
|
|
let candidate = dir.join(name);
|
|
if candidate.is_file() {
|
|
return Some(candidate);
|
|
}
|
|
// On Windows, also try with .exe suffix when the name has no extension.
|
|
if needs_exe {
|
|
let mut with_exe = dir.join(name);
|
|
with_exe.set_extension("exe");
|
|
if with_exe.is_file() {
|
|
return Some(with_exe);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn set_process_group(cmd: &mut Command) {
|
|
cmd.process_group(0);
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn set_process_group(_cmd: &mut Command) {}
|
|
|
|
/// Kill primitive covering the spawned bash AND every descendant it forks,
|
|
/// mirroring the same guarantee across platforms.
|
|
///
|
|
/// - Unix: the child's process group (set via [`set_process_group`]); kills go
|
|
/// to the whole group via `killpg`.
|
|
/// - Windows: a Job Object the child is assigned to at construction. A bare
|
|
/// `TerminateProcess` on bash leaves MSYS-forked grandchildren (e.g. `sleep`)
|
|
/// running — they hold the stdout/stderr pipes open, so the reap blocks until
|
|
/// they self-exit. Terminating the job kills the entire tree atomically.
|
|
///
|
|
/// Held for the whole `run`; `Drop` is the last-resort reaper if an explicit
|
|
/// kill was skipped or failed.
|
|
#[cfg(unix)]
|
|
struct KillGroup(Option<i32>);
|
|
|
|
#[cfg(unix)]
|
|
impl KillGroup {
|
|
fn new(_child: &tokio::process::Child, pid: Option<u32>) -> Self {
|
|
Self(pid.map(|p| p as i32))
|
|
}
|
|
|
|
/// Immediate SIGKILL of the process group. Sync; safe to call from Drop.
|
|
/// No grace period — used when the parent task is being torn down.
|
|
fn kill_immediate(&self) {
|
|
use nix::sys::signal::{killpg, Signal};
|
|
use nix::unistd::Pid;
|
|
if let Some(pid) = self.0 {
|
|
let _ = killpg(Pid::from_raw(pid), Signal::SIGKILL);
|
|
}
|
|
}
|
|
|
|
/// Graceful SIGTERM → 200ms async sleep → SIGKILL. Async; never blocks the runtime.
|
|
async fn kill_graceful(&self) {
|
|
use nix::sys::signal::{killpg, Signal};
|
|
use nix::unistd::Pid;
|
|
if let Some(pid) = self.0 {
|
|
let pgid = Pid::from_raw(pid);
|
|
let _ = killpg(pgid, Signal::SIGTERM);
|
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
let _ = killpg(pgid, Signal::SIGKILL);
|
|
}
|
|
}
|
|
|
|
/// Disarm the Drop-time kill once the child has been reaped explicitly.
|
|
fn disarm(&mut self) {
|
|
self.0 = None;
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
impl Drop for KillGroup {
|
|
fn drop(&mut self) {
|
|
self.kill_immediate();
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
struct KillGroup {
|
|
job: windows_sys::Win32::Foundation::HANDLE,
|
|
}
|
|
|
|
// SAFETY: `job` is a raw Win32 HANDLE (`*mut c_void`), which is neither `Send`
|
|
// nor `Sync` by default. The shell tool's async future holds a `KillGroup`
|
|
// across an `.await`, so it must be `Send` to be spawned. A job-object handle
|
|
// is a kernel object reference, not thread-affine: `TerminateJobObject` and
|
|
// `CloseHandle` are thread-safe, and Rust's `&self`/`&mut self` borrows still
|
|
// serialize access to the field. Moving or sharing it across threads is sound.
|
|
#[cfg(windows)]
|
|
#[allow(unsafe_code)]
|
|
unsafe impl Send for KillGroup {}
|
|
#[cfg(windows)]
|
|
#[allow(unsafe_code)]
|
|
unsafe impl Sync for KillGroup {}
|
|
|
|
#[cfg(windows)]
|
|
#[allow(unsafe_code)]
|
|
impl KillGroup {
|
|
fn new(child: &tokio::process::Child, _pid: Option<u32>) -> Self {
|
|
use std::mem::{size_of, zeroed};
|
|
use windows_sys::Win32::Foundation::HANDLE;
|
|
use windows_sys::Win32::System::JobObjects::{
|
|
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
|
|
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
|
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
|
};
|
|
|
|
// SAFETY: each call is a documented Win32 FFI call with arguments that
|
|
// satisfy its contract — a null SECURITY_ATTRIBUTES/name for an
|
|
// anonymous job, a zeroed #[repr(C)] info struct sized by size_of, and
|
|
// the live process handle from `child` (valid while it is running).
|
|
// A null job HANDLE on failure makes every later call a harmless no-op.
|
|
let job = unsafe {
|
|
let job: HANDLE = CreateJobObjectW(std::ptr::null(), std::ptr::null());
|
|
if !job.is_null() {
|
|
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = zeroed();
|
|
// KILL_ON_JOB_CLOSE: when the LAST handle to the job closes,
|
|
// Windows kills every process still in it. This is both the
|
|
// explicit-kill mechanism and the Drop-time safety net — and the
|
|
// reason the job HANDLE must outlive the child (see Drop).
|
|
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
|
SetInformationJobObject(
|
|
job,
|
|
JobObjectExtendedLimitInformation,
|
|
std::ptr::addr_of!(info).cast(),
|
|
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
|
);
|
|
if let Some(handle) = child.raw_handle() {
|
|
AssignProcessToJobObject(job, handle as HANDLE);
|
|
}
|
|
}
|
|
job
|
|
};
|
|
Self { job }
|
|
}
|
|
|
|
fn kill_immediate(&self) {
|
|
self.terminate();
|
|
}
|
|
|
|
async fn kill_graceful(&self) {
|
|
// A Job Object has no SIGTERM analogue; termination is atomic, so the
|
|
// graceful path is the same single terminate as the immediate path.
|
|
self.terminate();
|
|
}
|
|
|
|
fn terminate(&self) {
|
|
use windows_sys::Win32::System::JobObjects::TerminateJobObject;
|
|
if !self.job.is_null() {
|
|
// SAFETY: `self.job` is a valid job HANDLE for this struct's
|
|
// lifetime; exit code 137 mirrors the SIGKILL (128+9) we report on
|
|
// Unix.
|
|
unsafe {
|
|
TerminateJobObject(self.job, 137);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// No-op on Windows: the job is terminated explicitly, and closing the
|
|
/// handle on Drop with no live processes left is harmless. Kept for a
|
|
/// uniform call shape with the Unix guard.
|
|
fn disarm(&mut self) {}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[allow(unsafe_code)]
|
|
impl Drop for KillGroup {
|
|
fn drop(&mut self) {
|
|
use windows_sys::Win32::Foundation::CloseHandle;
|
|
if !self.job.is_null() {
|
|
// Closing the last job handle triggers KILL_ON_JOB_CLOSE, killing any
|
|
// process still in the job — the last-resort reaper. The handle is
|
|
// held until here precisely so this fires no earlier than run end.
|
|
// SAFETY: `self.job` is a valid HANDLE created in `new` and closed
|
|
// exactly once here.
|
|
unsafe {
|
|
CloseHandle(self.job);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback for targets that are neither unix nor windows: no process-tree kill
|
|
// primitive is wired up, so timeouts rely on the cross-platform start_kill in
|
|
// `run`. Keeps the crate compiling everywhere.
|
|
#[cfg(not(any(unix, windows)))]
|
|
struct KillGroup;
|
|
|
|
#[cfg(not(any(unix, windows)))]
|
|
impl KillGroup {
|
|
fn new(_child: &tokio::process::Child, _pid: Option<u32>) -> Self {
|
|
Self
|
|
}
|
|
fn kill_immediate(&self) {}
|
|
async fn kill_graceful(&self) {}
|
|
fn disarm(&mut self) {}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct CapturedStream {
|
|
bytes: Vec<u8>,
|
|
/// Total bytes the process produced (may exceed bytes.len() if capped).
|
|
total_bytes: usize,
|
|
capped: bool,
|
|
}
|
|
|
|
async fn read_capped<R: AsyncRead + Unpin>(mut r: R) -> CapturedStream {
|
|
let mut out = CapturedStream::default();
|
|
let mut chunk = vec![0u8; READ_CHUNK];
|
|
loop {
|
|
match r.read(&mut chunk).await {
|
|
Ok(0) => break,
|
|
Ok(n) => {
|
|
out.total_bytes = out.total_bytes.saturating_add(n);
|
|
if !out.capped {
|
|
let remaining = CAPTURE_CAP.saturating_sub(out.bytes.len());
|
|
if remaining == 0 {
|
|
out.capped = true;
|
|
} else {
|
|
let take = n.min(remaining);
|
|
out.bytes.extend_from_slice(&chunk[..take]);
|
|
if out.bytes.len() >= CAPTURE_CAP {
|
|
out.capped = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn finalize_stream(
|
|
state: &SharedState,
|
|
call_id: u64,
|
|
label: &str,
|
|
cap: CapturedStream,
|
|
notes: &mut Vec<String>,
|
|
) -> (String, bool, Option<String>) {
|
|
let CapturedStream {
|
|
bytes: buf,
|
|
total_bytes,
|
|
capped,
|
|
} = cap;
|
|
let captured_len = buf.len();
|
|
let line_count = buf.iter().filter(|b| **b == b'\n').count();
|
|
let needs_truncate = capped || captured_len > MAX_BYTES || line_count > MAX_LINES;
|
|
|
|
if !needs_truncate {
|
|
return (lossy(buf), false, None);
|
|
}
|
|
|
|
let artifact_path = crate::shim::artifact_dir(state.session_dir.path())
|
|
.join(format!("{call_id:06}.{label}.txt"));
|
|
let artifact_str = match std::fs::write(&artifact_path, &buf) {
|
|
Ok(()) => {
|
|
rotate_artifacts(state, artifact_path.clone());
|
|
Some(artifact_path.to_string_lossy().into_owned())
|
|
}
|
|
Err(e) => {
|
|
notes.push(format!(
|
|
"{label}: artifact write failed ({}): {e}",
|
|
artifact_path.display()
|
|
));
|
|
None
|
|
}
|
|
};
|
|
|
|
let tail_start = captured_len.saturating_sub(TAIL_BYTES);
|
|
let tail_aligned = align_to_char_boundary(&buf, tail_start);
|
|
let tail = lossy(buf[tail_aligned..].to_vec());
|
|
|
|
let cap_note = if capped {
|
|
format!(
|
|
" (capture capped at {} bytes; further output discarded)",
|
|
CAPTURE_CAP
|
|
)
|
|
} else {
|
|
String::new()
|
|
};
|
|
let artifact_suffix = match &artifact_str {
|
|
Some(p) => format!("; captured output (first 10MB) at {p}"),
|
|
None => "; artifact unavailable".into(),
|
|
};
|
|
let notice = format!(
|
|
"[truncated: showing last {} bytes; {} bytes captured / {} lines / {} bytes total{cap_note}{artifact_suffix}]\n",
|
|
tail.len(),
|
|
captured_len,
|
|
line_count,
|
|
total_bytes,
|
|
);
|
|
let mut out = String::with_capacity(notice.len() + tail.len());
|
|
out.push_str(¬ice);
|
|
out.push_str(&tail);
|
|
(out, true, artifact_str)
|
|
}
|
|
|
|
fn align_to_char_boundary(buf: &[u8], start: usize) -> usize {
|
|
let mut i = start.min(buf.len());
|
|
while i < buf.len() && (buf[i] & 0xC0) == 0x80 {
|
|
i += 1;
|
|
}
|
|
i
|
|
}
|
|
|
|
fn lossy(buf: Vec<u8>) -> String {
|
|
String::from_utf8(buf).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
|
|
}
|
|
|
|
fn rotate_artifacts(state: &SharedState, new_path: PathBuf) {
|
|
let mut ring = match state.artifacts.lock() {
|
|
Ok(g) => g,
|
|
Err(p) => p.into_inner(),
|
|
};
|
|
ring.push_back(new_path);
|
|
while ring.len() > ARTIFACT_RING_SIZE {
|
|
if let Some(old) = ring.pop_front() {
|
|
let _ = std::fs::remove_file(old);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::shim::Shim;
|
|
use serde_json::Value;
|
|
use tempfile::tempdir;
|
|
|
|
fn make_state(cwd: &std::path::Path) -> SharedState {
|
|
let shim = Shim::install().expect("shim install");
|
|
SharedState::new(cwd.to_path_buf(), shim).expect("state new")
|
|
}
|
|
|
|
/// 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()) {
|
|
Some(t) => t.text.clone(),
|
|
None => panic!("no text content"),
|
|
};
|
|
serde_json::from_str(&text).expect("json")
|
|
}
|
|
|
|
#[test]
|
|
fn timeout_bounds_preserve_default_and_cap_requests_at_twenty_minutes() {
|
|
assert_eq!(effective_timeout_ms(None), 120_000);
|
|
assert_eq!(effective_timeout_ms(Some(120_000)), 120_000);
|
|
assert_eq!(effective_timeout_ms(Some(1_200_000)), 1_200_000);
|
|
assert_eq!(effective_timeout_ms(Some(1_200_001)), 1_200_000);
|
|
assert_eq!(effective_timeout_ms(Some(u64::MAX)), 1_200_000);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn basic_echo() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let state = make_state(dir.path());
|
|
let r = run(
|
|
&state,
|
|
ShellParams {
|
|
command: "echo hello".into(),
|
|
workdir: None,
|
|
timeout_ms: Some(5_000),
|
|
},
|
|
CancellationToken::new(),
|
|
)
|
|
.await
|
|
.expect("ok");
|
|
let v = body(r);
|
|
assert_eq!(v["exit_code"], 0);
|
|
assert_eq!(v["stdout"], "hello\n");
|
|
assert_eq!(v["timed_out"], false);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn timeout_fires() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let state = make_state(dir.path());
|
|
let r = run(
|
|
&state,
|
|
ShellParams {
|
|
// Short sleep, not 999: the kill path must actually terminate
|
|
// the process tree on timeout. If a regression leaves the child
|
|
// (or an MSYS grandchild) orphaned, the test stalls until this
|
|
// brief sleep self-exits — ~5s, not ~16min — so the failure
|
|
// stays visible instead of hiding behind a 999s sleep.
|
|
command: "sleep 5".into(),
|
|
workdir: None,
|
|
timeout_ms: Some(150),
|
|
},
|
|
CancellationToken::new(),
|
|
)
|
|
.await
|
|
.expect("ok");
|
|
let v = body(r);
|
|
assert_eq!(v["timed_out"], true);
|
|
assert_eq!(v["exit_code"], 124);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn workdir_is_honored() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let sub = dir.path().join("sub");
|
|
std::fs::create_dir(&sub).expect("mkdir sub");
|
|
let state = make_state(dir.path());
|
|
let r = run(
|
|
&state,
|
|
ShellParams {
|
|
command: "pwd".into(),
|
|
workdir: Some(sub.display().to_string()),
|
|
timeout_ms: Some(5_000),
|
|
},
|
|
CancellationToken::new(),
|
|
)
|
|
.await
|
|
.expect("ok");
|
|
let v = body(r);
|
|
let stdout = v["stdout"].as_str().unwrap_or("");
|
|
// Compare canonicalized paths (macOS /tmp -> /private/tmp, etc.).
|
|
let sub_canon = std::fs::canonicalize(&sub).expect("canon");
|
|
assert!(
|
|
stdout
|
|
.trim()
|
|
.ends_with(sub_canon.to_string_lossy().as_ref())
|
|
|| stdout.contains(sub.file_name().unwrap().to_str().unwrap()),
|
|
"stdout: {stdout}"
|
|
);
|
|
}
|
|
|
|
// --- is_windows_apps_alias predicate tests (cross-host) ---
|
|
|
|
#[test]
|
|
fn test_windows_apps_alias_detected_typical_path() {
|
|
// Typical WSL alias: %LOCALAPPDATA%\Microsoft\WindowsApps\bash.exe
|
|
// Forward-slash form parses on both Windows and non-Windows hosts.
|
|
assert!(
|
|
is_windows_apps_alias(Path::new(
|
|
"C:/Users/alice/AppData/Local/Microsoft/WindowsApps/bash.exe"
|
|
)),
|
|
"standard WindowsApps path must be detected as an alias"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_windows_apps_alias_detected_case_insensitive() {
|
|
assert!(
|
|
is_windows_apps_alias(Path::new(
|
|
"C:/Users/alice/AppData/Local/MICROSOFT/WINDOWSAPPS/bash.exe"
|
|
)),
|
|
"WindowsApps detection must be case-insensitive"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_windows_apps_alias_rejected_real_git_bash() {
|
|
assert!(
|
|
!is_windows_apps_alias(Path::new("C:/Program Files/Git/bin/bash.exe")),
|
|
"real Git Bash must not be detected as a WindowsApps alias"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_windows_apps_alias_rejected_system32_bash() {
|
|
assert!(
|
|
!is_windows_apps_alias(Path::new("C:/Windows/System32/bash.exe")),
|
|
"System32 bash must not be detected as a WindowsApps alias"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_windows_apps_alias_rejected_partial_component_match() {
|
|
// A directory named "Microsoft" without a "WindowsApps" sibling must not match.
|
|
assert!(
|
|
!is_windows_apps_alias(Path::new("C:/Microsoft/SomeOtherDir/bash.exe")),
|
|
"path with Microsoft but not WindowsApps must not be detected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_windows_apps_alias_rejected_unix_bash() {
|
|
assert!(
|
|
!is_windows_apps_alias(Path::new("/usr/bin/bash")),
|
|
"Unix bash must not be detected as a WindowsApps alias"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(all(test, windows))]
|
|
mod windows_resolver_tests {
|
|
use super::*;
|
|
use std::env;
|
|
use std::sync::Mutex;
|
|
use tempfile::tempdir;
|
|
|
|
// Process-global env mutation guard: tests that mutate BUZZ_SHELL,
|
|
// SystemRoot, or GIT_BASH must hold this lock for the duration of the
|
|
// test so parallel test threads cannot race on these env vars.
|
|
static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
|
|
|
fn touch(path: &Path) {
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent).expect("mkdir");
|
|
}
|
|
std::fs::write(path, b"").expect("touch");
|
|
}
|
|
|
|
#[test]
|
|
fn buzz_shell_override_wins_over_everything() {
|
|
let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
|
|
// BUZZ_SHELL pointing at a real file must be returned without probing
|
|
// the standard Git-for-Windows locations or PATH.
|
|
let dir = tempdir().expect("tempdir");
|
|
let fake_bash = dir.path().join("my-bash.exe");
|
|
touch(&fake_bash);
|
|
// Temporarily set BUZZ_SHELL; clean up after the test.
|
|
env::set_var("BUZZ_SHELL", &fake_bash);
|
|
let result = resolve_bash("");
|
|
env::remove_var("BUZZ_SHELL");
|
|
let (resolved, _name) = result.expect("BUZZ_SHELL override should resolve");
|
|
assert_eq!(resolved, fake_bash);
|
|
}
|
|
|
|
#[test]
|
|
fn buzz_shell_override_skipped_when_path_absent() {
|
|
let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
|
|
// If BUZZ_SHELL points at a non-existent path the resolver must fall
|
|
// through rather than returning a dead path.
|
|
env::set_var("BUZZ_SHELL", r"C:\does\not\exist\bash.exe");
|
|
// We cannot easily assert the fallback here without a full Git install,
|
|
// but we can assert the override itself is not returned.
|
|
let result = resolve_bash("");
|
|
env::remove_var("BUZZ_SHELL");
|
|
if let Ok((resolved, _)) = result {
|
|
assert_ne!(
|
|
resolved.to_str().unwrap_or(""),
|
|
r"C:\does\not\exist\bash.exe",
|
|
"non-existent BUZZ_SHELL must not be returned"
|
|
);
|
|
}
|
|
// An Err is also acceptable (no Git installed on test host).
|
|
}
|
|
|
|
/// Explicit BUZZ_SHELL bare name resolves through PATH and uses NO System32
|
|
/// exclusion — cmd/pwsh live in System32 legitimately.
|
|
#[test]
|
|
fn buzz_shell_explicit_bare_name_resolves_from_system32() {
|
|
let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
|
|
// Simulate cmd.exe living in a dir that would be excluded by the WSL guard.
|
|
// The explicit BUZZ_SHELL branch must NOT skip System32.
|
|
let sys32 = tempdir().expect("sys32");
|
|
let fake_cmd = sys32.path().join("cmd.exe");
|
|
touch(&fake_cmd);
|
|
|
|
// Build a path_env with only sys32 (the WSL exclusion would skip this dir
|
|
// for bash.exe, but must NOT skip it for an explicit BUZZ_SHELL).
|
|
let path_env = env::join_paths([sys32.path().to_path_buf()]).expect("join");
|
|
env::set_var("BUZZ_SHELL", "cmd");
|
|
// Override SystemRoot so the exclusion would trigger on sys32 if applied.
|
|
let old_sysroot = env::var_os("SystemRoot");
|
|
env::set_var("SystemRoot", sys32.path());
|
|
|
|
let result = resolve_bash(path_env.to_str().expect("utf8"));
|
|
|
|
env::remove_var("BUZZ_SHELL");
|
|
match old_sysroot {
|
|
Some(v) => env::set_var("SystemRoot", v),
|
|
None => env::remove_var("SystemRoot"),
|
|
}
|
|
|
|
let (resolved, name) =
|
|
result.expect("explicit BUZZ_SHELL=cmd should resolve even from System32-like dir");
|
|
assert_eq!(resolved, fake_cmd);
|
|
assert_eq!(name, "cmd");
|
|
}
|
|
|
|
/// Implicit bash.exe scan still skips System32 (WSL guard intact).
|
|
#[test]
|
|
fn implicit_bash_scan_still_skips_system32() {
|
|
let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
|
|
// Same setup: bash.exe is only in a dir that is under SystemRoot.
|
|
// Without an explicit BUZZ_SHELL, the fallback scan must skip it.
|
|
let sys32 = tempdir().expect("sys32");
|
|
touch(&sys32.path().join("bash.exe"));
|
|
|
|
let path_env = env::join_paths([sys32.path().to_path_buf()]).expect("join");
|
|
// No BUZZ_SHELL — trigger the implicit bash fallback scan.
|
|
env::remove_var("BUZZ_SHELL");
|
|
env::remove_var("GIT_BASH");
|
|
// Point SystemRoot at sys32's parent so sys32 is "under SystemRoot".
|
|
let parent = sys32.path().parent().unwrap().to_path_buf();
|
|
let old_sysroot = env::var_os("SystemRoot");
|
|
env::set_var("SystemRoot", &parent);
|
|
|
|
let result = resolve_bash(path_env.to_str().expect("utf8"));
|
|
|
|
match old_sysroot {
|
|
Some(v) => env::set_var("SystemRoot", v),
|
|
None => env::remove_var("SystemRoot"),
|
|
}
|
|
|
|
// Should be Err (no Git installed on test host, and the only bash.exe was
|
|
// under SystemRoot so it was skipped). Ok is also acceptable if git bash
|
|
// happens to be installed at the fixed Program Files path — we just assert
|
|
// the System32 bash was NOT returned.
|
|
if let Ok((resolved, _)) = result {
|
|
assert!(
|
|
!resolved.starts_with(sys32.path()),
|
|
"implicit bash scan must not return the System32 bash: {resolved:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The bootstrap hint (resolved_shell field) and the spawn path are the
|
|
/// same object — both come from SharedState.resolved_shell.
|
|
/// Verify that constructing SharedState with BUZZ_SHELL set produces a
|
|
/// resolved_shell whose display name appears in bootstrap_instructions.
|
|
#[test]
|
|
fn shared_state_bootstrap_hint_matches_resolved_shell() {
|
|
let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
|
|
let dir = tempdir().expect("tempdir");
|
|
let fake_pwsh = dir.path().join("pwsh.exe");
|
|
touch(&fake_pwsh);
|
|
env::set_var("BUZZ_SHELL", &fake_pwsh);
|
|
|
|
let shim = crate::shim::Shim::install().expect("shim");
|
|
let state = SharedState::new(dir.path().to_path_buf(), shim).expect("state");
|
|
|
|
env::remove_var("BUZZ_SHELL");
|
|
|
|
let (_, name) = state.resolved_shell.as_ref().expect("resolved ok");
|
|
assert_eq!(name, "pwsh");
|
|
assert!(
|
|
state.bootstrap_instructions.contains("pwsh"),
|
|
"bootstrap must mention the resolved shell name"
|
|
);
|
|
}
|
|
|
|
/// F3: BUZZ_SHELL bare command name (e.g. "pwsh") resolved through PATH.
|
|
/// When pwsh.exe is on PATH, resolve_bash must return it and report "pwsh".
|
|
#[test]
|
|
fn buzz_shell_bare_name_resolved_through_path_when_present() {
|
|
let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
|
|
let dir = tempdir().expect("tempdir");
|
|
let fake_pwsh = dir.path().join("pwsh.exe");
|
|
touch(&fake_pwsh);
|
|
|
|
let path_env = env::join_paths([dir.path().to_path_buf()]).expect("join");
|
|
env::set_var("BUZZ_SHELL", "pwsh");
|
|
let result = resolve_bash(path_env.to_str().expect("utf8"));
|
|
env::remove_var("BUZZ_SHELL");
|
|
|
|
let (resolved, name) = result.expect("bare BUZZ_SHELL=pwsh should resolve from PATH");
|
|
assert_eq!(resolved, fake_pwsh, "should resolve to pwsh.exe on PATH");
|
|
assert_eq!(name, "pwsh", "display name must match resolved shell");
|
|
}
|
|
|
|
/// F3: BUZZ_SHELL bare command name absent from PATH → fall through, do not
|
|
/// report pwsh as the active shell.
|
|
#[test]
|
|
fn buzz_shell_bare_name_absent_from_path_falls_through() {
|
|
let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
|
|
// Set BUZZ_SHELL to a command that won't be on any real PATH.
|
|
env::set_var("BUZZ_SHELL", "buzz-shell-does-not-exist-xyz");
|
|
let result = resolve_bash("");
|
|
env::remove_var("BUZZ_SHELL");
|
|
if let Ok((resolved, name)) = result {
|
|
assert_ne!(
|
|
resolved.file_name().and_then(|n| n.to_str()).unwrap_or(""),
|
|
"buzz-shell-does-not-exist-xyz.exe",
|
|
"absent BUZZ_SHELL must not be returned as the resolved path"
|
|
);
|
|
assert_ne!(
|
|
name, "buzz-shell-does-not-exist-xyz",
|
|
"absent BUZZ_SHELL must not be reported as the shell name"
|
|
);
|
|
}
|
|
// Err is also acceptable (no Git on test host).
|
|
}
|
|
|
|
#[test]
|
|
fn git_cmd_on_path_resolves_sibling_git_bash() {
|
|
let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
|
|
let dir = tempdir().expect("tempdir");
|
|
let git = dir.path().join("Git").join("cmd").join("git.exe");
|
|
let bash = dir.path().join("Git").join("bin").join("bash.exe");
|
|
touch(&git);
|
|
touch(&bash);
|
|
|
|
let path_env = env::join_paths([git.parent().expect("cmd dir")]).expect("join");
|
|
let old_buzz_shell = env::var_os("BUZZ_SHELL");
|
|
let old_git_bash = env::var_os("GIT_BASH");
|
|
env::remove_var("BUZZ_SHELL");
|
|
env::remove_var("GIT_BASH");
|
|
|
|
let result = resolve_bash(path_env.to_str().expect("utf8"));
|
|
|
|
match old_buzz_shell {
|
|
Some(value) => env::set_var("BUZZ_SHELL", value),
|
|
None => env::remove_var("BUZZ_SHELL"),
|
|
}
|
|
match old_git_bash {
|
|
Some(value) => env::set_var("GIT_BASH", value),
|
|
None => env::remove_var("GIT_BASH"),
|
|
}
|
|
|
|
assert_eq!(
|
|
result
|
|
.expect("Git cmd PATH entry should resolve its sibling bash")
|
|
.0,
|
|
bash
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn program_files_x86_git_bash_is_found() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let program_files_x86 = dir.path().join("Program Files (x86)");
|
|
let bash = program_files_x86.join("Git").join("bin").join("bash.exe");
|
|
touch(&bash);
|
|
|
|
assert_eq!(
|
|
git_bash_from_standard_path_bases([None, Some(program_files_x86), None]),
|
|
Some(bash)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn path_scan_skips_system32_and_returns_absolute() {
|
|
// A bash.exe under %SystemRoot% (where WSL's launcher lives) must be
|
|
// skipped; a bash.exe elsewhere on PATH is returned as an absolute path.
|
|
let sys_root = tempdir().expect("sysroot");
|
|
let real = tempdir().expect("real");
|
|
touch(&sys_root.path().join("System32").join("bash.exe"));
|
|
let real_bash = real.path().join("bash.exe");
|
|
touch(&real_bash);
|
|
|
|
let path_env =
|
|
env::join_paths([sys_root.path().join("System32"), real.path().to_path_buf()])
|
|
.expect("join");
|
|
|
|
let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(sys_root.path()))
|
|
.expect("bash found outside System32");
|
|
assert!(found.is_absolute());
|
|
assert!(!found.starts_with(sys_root.path()));
|
|
assert_eq!(found, real_bash);
|
|
}
|
|
|
|
#[test]
|
|
fn path_scan_returns_none_when_only_system32_has_bash() {
|
|
// If the ONLY bash.exe on PATH is under System32, the scan finds nothing.
|
|
let sys_root = tempdir().expect("sysroot");
|
|
touch(&sys_root.path().join("System32").join("bash.exe"));
|
|
let path_env = env::join_paths([sys_root.path().join("System32")]).expect("join");
|
|
|
|
let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(sys_root.path()));
|
|
assert!(found.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn path_scan_skips_system32_when_path_case_differs_from_root() {
|
|
// Windows paths are case-insensitive; a PATH entry spelled differently from
|
|
// %SystemRoot% (e.g. `...\WINDOWS\System32` vs root `...\Windows`) must STILL
|
|
// be excluded, or WSL's bash.exe leaks through. Build the System32 dir under a
|
|
// genuinely upper-cased sibling component so the exclusion can only pass via a
|
|
// case-insensitive compare, not a literal `starts_with`.
|
|
let base = tempdir().expect("base");
|
|
let root = base.path().join("Windows");
|
|
let upper = base.path().join("WINDOWS");
|
|
let sys32 = upper.join("System32");
|
|
touch(&sys32.join("bash.exe"));
|
|
|
|
let path_env = env::join_paths([sys32]).expect("join");
|
|
let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(&root));
|
|
assert!(
|
|
found.is_none(),
|
|
"case-divergent System32 must still be excluded"
|
|
);
|
|
}
|
|
|
|
/// PATH-only discovery — a bash.exe custom-installed on PATH (not under
|
|
/// the standard Program Files locations) must be found by the runtime
|
|
/// resolver. This verifies the PATH fallback in resolve_bash: a custom
|
|
/// install that lives outside Program Files is still usable as a shell.
|
|
#[test]
|
|
fn path_only_bash_is_found_by_scan() {
|
|
// scan_path_for_bash is the runtime resolver's PATH fallback helper.
|
|
// Verify it returns the bash.
|
|
let real = tempdir().expect("real");
|
|
let real_bash = real.path().join("bash.exe");
|
|
touch(&real_bash);
|
|
|
|
let path_env = env::join_paths([real.path().to_path_buf()]).expect("join");
|
|
let sys_root = tempdir().expect("sysroot"); // empty — no System32 here
|
|
|
|
let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(sys_root.path()))
|
|
.expect("bash on PATH must be found");
|
|
assert_eq!(found, real_bash);
|
|
}
|
|
|
|
/// WSL alias rejection — when WindowsApps\bash.exe is first on PATH and a
|
|
/// legitimate Git Bash follows, the scanner must skip the alias and return
|
|
/// the real one (alias-first / real-bash-second ordering).
|
|
#[test]
|
|
fn windows_apps_alias_first_real_bash_second_returns_real() {
|
|
let base = tempdir().expect("base");
|
|
|
|
// Simulate %LOCALAPPDATA%\Microsoft\WindowsApps structure.
|
|
let microsoft = base.path().join("Microsoft");
|
|
let windows_apps = microsoft.join("WindowsApps");
|
|
std::fs::create_dir_all(&windows_apps).expect("mkdir WindowsApps");
|
|
let alias_bash = windows_apps.join("bash.exe");
|
|
touch(&alias_bash);
|
|
|
|
// Legitimate Git Bash in a separate directory.
|
|
let git_bin = base.path().join("git").join("bin");
|
|
std::fs::create_dir_all(&git_bin).expect("mkdir git/bin");
|
|
let real_bash = git_bin.join("bash.exe");
|
|
touch(&real_bash);
|
|
|
|
let path_env = env::join_paths([windows_apps.clone(), git_bin.clone()]).expect("join");
|
|
let sys_root = tempdir().expect("sysroot"); // empty
|
|
|
|
let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(sys_root.path()))
|
|
.expect("real bash must be found after skipping alias");
|
|
assert_eq!(
|
|
found, real_bash,
|
|
"must skip WindowsApps alias and return real bash"
|
|
);
|
|
}
|
|
|
|
/// WSL alias rejection — when only WindowsApps\bash.exe is on PATH (no real
|
|
/// Git Bash installed), the scanner must return None rather than the alias.
|
|
#[test]
|
|
fn windows_apps_alias_only_returns_none() {
|
|
let base = tempdir().expect("base");
|
|
|
|
let microsoft = base.path().join("Microsoft");
|
|
let windows_apps = microsoft.join("WindowsApps");
|
|
std::fs::create_dir_all(&windows_apps).expect("mkdir WindowsApps");
|
|
let alias_bash = windows_apps.join("bash.exe");
|
|
touch(&alias_bash);
|
|
|
|
let path_env = env::join_paths([windows_apps.clone()]).expect("join");
|
|
let sys_root = tempdir().expect("sysroot"); // empty
|
|
|
|
let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(sys_root.path()));
|
|
assert!(
|
|
found.is_none(),
|
|
"alias-only PATH must return None, not the WSL launcher"
|
|
);
|
|
}
|
|
}
|