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
92 changes: 91 additions & 1 deletion crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ pub enum AcpError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),

#[error("failed to spawn agent command {command:?}: {source}{missing}")]
Spawn {
command: String,
source: std::io::Error,
/// Set when `source` is NotFound, so operators read "not found on
/// PATH" instead of a bare errno that reads like a missing directory.
missing: String,
},

#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),

Expand Down Expand Up @@ -534,7 +543,20 @@ impl AcpClient {
"codex" | "codex-acp" => Some(StandardAdapterKind::Codex),
_ => None,
};
let mut child = cmd.spawn()?;
let mut child = cmd.spawn().map_err(|source| {
// #6473: "No such file or directory" alone reads like a missing
// working directory; name the command and say PATH explicitly.
let missing = if source.kind() == std::io::ErrorKind::NotFound {
" — binary not found on PATH".to_string()
} else {
String::new()
};
AcpError::Spawn {
command: command.to_string(),
source,
missing,
}
})?;

let stdin = child
.stdin
Expand Down Expand Up @@ -5028,3 +5050,71 @@ mod tests {
);
}
}

#[cfg(test)]
mod spawn_error_tests {
use super::{AcpClient, AcpError};

const MISSING_BINARY: &str = "buzz-acp-nonexistent-binary-6473";

// #6473: a failed spawn must name the command and, when the binary is
// simply absent, say so in PATH terms an operator recognises.
#[tokio::test]
async fn spawn_error_names_missing_command_and_says_not_on_path() {
let err = AcpClient::spawn(MISSING_BINARY, &[], &[], false)
.await
.err()
.expect("spawn of a nonexistent binary must fail");
match &err {
AcpError::Spawn { command, .. } => {
assert_eq!(command, MISSING_BINARY);
}
other => panic!("expected AcpError::Spawn, got {other}"),
}
let msg = err.to_string();
assert!(
msg.contains(MISSING_BINARY),
"message must contain the command name, got: {msg}"
);
assert!(
msg.to_lowercase().contains("not found") && msg.to_lowercase().contains("path"),
"message must identify the binary as not found on PATH, got: {msg}"
);
}

#[cfg(unix)]
#[tokio::test]
async fn spawn_error_names_command_on_non_notfound_failure() {
use std::os::unix::fs::PermissionsExt;

// A path that exists but is not executable: io::ErrorKind::PermissionDenied.
let dir = std::env::temp_dir().join(format!(
"buzz-acp-notexec-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("not-executable-script");
std::fs::write(&path, "#!/usr/bin/env bash\necho hi\n").expect("write file");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
.expect("set permissions");

let err = AcpClient::spawn(path.to_str().expect("utf8 path"), &[], &[], false)
.await
.err()
.expect("spawn of a non-executable file must fail");
match &err {
AcpError::Spawn { command, .. } => {
assert_eq!(command, path.to_str().expect("utf8 path"));
}
other => panic!("expected AcpError::Spawn, got {other}"),
}
let msg = err.to_string();
assert!(
msg.contains(path.to_str().expect("utf8 path")),
"non-NotFound spawn failures must also name the command, got: {msg}"
);

let _ = std::fs::remove_dir_all(&dir);
}
}