mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
discovered in https://github.com/block/buzz/issues/7742#issuecomment-5783512985 ## Summary Goose uses its native shell, so it bypassed the Git identity and signing setup inside `buzz-dev-mcp`. Agent commits could use the host Git identity, while MCP shells had a separate config. This moves Git setup into `buzz-acp`, where both paths receive the same agent identity, Nostr signer, and relay-scoped credential helper. Before: ```text Buzz Desktop └─ buzz-acp ← Desktop supplied relay Git authentication ├─ Goose → native shell ← no agent Git author or signing setup └─ Buzz Agent └─ buzz-dev-mcp └─ shell ← MCP created the keyfile and Git signing config ``` After: ```text Buzz Desktop or Sprig └─ buzz-acp ← owns the keyfile, identity, signing, and scoped credentials ├─ Goose → native shell ← inherits the complete Git environment └─ Buzz Agent └─ buzz-dev-mcp └─ shell ← receives the same Git environment via mcpServers[].env ``` The harness keeps the signing key in a private temporary file and removes it on normal exit, startup failure, and graceful termination. It composes inherited `GIT_CONFIG_*` entries, forwards the complete block to MCP servers, and bundles the helpers through the ACP multicall entrypoint. For standard `buzz-acp` launches, Desktop and Sprig no longer write Git config. A custom Desktop ACP command bypasses the harness, so Desktop preserves its prior relay-scoped credential helper for that supported override. This changes Git settings for every ACP runtime. The startup signal path also changes so a termination during adapter initialization removes the keyfile. The temporary file can remain after SIGKILL or a machine crash. ### Related issue Related: #6177 covers stricter commit identity enforcement and touches the same Git paths. That PR and this one need to be reconciled before either lands. No issue was found for this runtime bootstrap gap. ### Testing - Ran the ignored `git_runtime_tests` with real Goose 1.50.0 and Buzz Agent ACP sessions. `real_goose_native_git_shell` passed again on this PR branch with `BUZZ_TEST_BIN_DIR` pointing to built binaries. Goose made a signed commit and tag through its native shell; Buzz Agent did the same through its MCP shell. Both verified signatures, identity, credential scoping, and keyfile cleanup. - Built the Linux Sprig image and ran `scripts/test-sprig-image.sh buzz-sprig:acp-git-bootstrap`. The container made and verified a signed commit and tag, then confirmed keyfile removal after SIGTERM. - `just ci` passed on the original branch. After rebasing onto `main` and addressing review feedback, the ACP Git bootstrap integration tests, focused desktop smoke test, custom-command regression test, formatting, and ACP/Desktop Clippy passed. The pre-push hook hit two timing-sensitive ACP deadline tests under parallel load; both passed alone and the complete ACP package passed serially (964 library, 2 Git bootstrap, 9 pool lifecycle tests). The push used `--no-verify` after those checks; GitHub CI is rerunning on the new head. - Under injected `BUZZ_ACP_ALLOWED_RESPOND_TO`, `BUZZ_ACP_LAZY_POOL`, and `BUZZ_ACP_IDLE_POOL_SLEEP`, the clean-env serial package command in `crates/buzz-acp/TESTING.md` passed: 964 active library tests, 2 Git bootstrap tests, and 9 pool lifecycle tests. Both named Databricks OAuth tests passed locally; another reviewer reproduced their environment-specific failures on `main`. - No authenticated relay clone or push was run. The local probes used disposable repositories and did not contact a relay. Generated with Codex --------- Signed-off-by: Salman Mohammed <smohammed@squareup.com>
212 lines
8.1 KiB
Rust
212 lines
8.1 KiB
Rust
#![cfg_attr(not(windows), forbid(unsafe_code))]
|
|
#![cfg_attr(windows, deny(unsafe_code))]
|
|
use rmcp::{
|
|
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
|
|
model::{CallToolResult, ServerCapabilities, ServerInfo},
|
|
tool, tool_handler, tool_router,
|
|
transport::stdio,
|
|
ErrorData, ServerHandler, ServiceExt,
|
|
};
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
|
|
mod paths;
|
|
mod read_file;
|
|
mod rg;
|
|
mod shell;
|
|
mod shim;
|
|
mod str_replace;
|
|
mod todo;
|
|
mod tree;
|
|
mod view_image;
|
|
|
|
#[derive(Clone)]
|
|
struct DevMcp {
|
|
state: Arc<shell::SharedState>,
|
|
todos: Arc<todo::TodoState>,
|
|
tool_router: ToolRouter<DevMcp>,
|
|
}
|
|
|
|
#[tool_router]
|
|
impl DevMcp {
|
|
fn new(state: Arc<shell::SharedState>) -> Self {
|
|
Self {
|
|
state,
|
|
todos: Arc::new(todo::TodoState::new()),
|
|
tool_router: Self::tool_router(),
|
|
}
|
|
}
|
|
|
|
#[tool(
|
|
name = "shell",
|
|
description = "Run a shell command (bash by default; set `BUZZ_SHELL` to use cmd, PowerShell, or another shell). Ephemeral process per call. Output tail-truncated to ~8KB for the LLM; full output (first 10MB) saved to artifact file. timeout_ms defaults to 120000 (2 min) if omitted; capped at 1,200,000 (20 min). For long-running commands (git push with hooks, cargo build, test suites), use 300000+. On PATH: rg (prefer over grep; flags: -n -i -l -g <glob> -C <n> --files), tree (flags: -d <depth>; shows line counts), and buzz (Buzz relay CLI — run buzz --help for commands)."
|
|
)]
|
|
async fn shell(
|
|
&self,
|
|
Parameters(p): Parameters<shell::ShellParams>,
|
|
context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
shell::run(&self.state, p, context.ct).await
|
|
}
|
|
|
|
#[tool(
|
|
name = "read_file",
|
|
description = "Read a text file and return its contents with line numbers. Returns lines in `{number}:{content}` format. Use `offset` (0-based) and `limit` (default 2000) to window into large files. Path resolved relative to workdir (defaults to server cwd). Prefer over cat/head/tail."
|
|
)]
|
|
async fn read_file(
|
|
&self,
|
|
Parameters(p): Parameters<read_file::ReadFileParams>,
|
|
) -> Result<String, ErrorData> {
|
|
read_file::run(&self.state, p)
|
|
}
|
|
|
|
#[tool(
|
|
name = "view_image",
|
|
description = "Load an image from a file path, http(s) URL, or data: URL and return it as an MCP image content block that multimodal LLMs (Anthropic, OpenAI-compatible, etc.) can see. Resizes to a longest-edge of 1568px by default (override with `max_dim`, range 64..=2048). Pass-through for already-small PNG/JPEG; transcodes oversize input to PNG (if alpha) or JPEG q85. Animated GIF/WebP rejected — provide a still frame. Hard cap 20 MiB source, ~4 MiB on the wire. Relative paths resolve under `workdir` (defaults to server cwd) and may not escape it."
|
|
)]
|
|
async fn view_image(
|
|
&self,
|
|
Parameters(p): Parameters<view_image::ViewImageParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
view_image::run(&self.state, p).await
|
|
}
|
|
|
|
#[tool(
|
|
name = "str_replace",
|
|
description = "Atomic find-and-replace in a file. old_str must occur exactly once unless replace_all is true, in which case all occurrences are replaced. Returns a unified diff. Path resolved relative to workdir (defaults to server cwd). Prefer over sed/awk."
|
|
)]
|
|
async fn str_replace(
|
|
&self,
|
|
Parameters(p): Parameters<str_replace::StrReplaceParams>,
|
|
) -> Result<String, ErrorData> {
|
|
str_replace::run(&self.state, p)
|
|
}
|
|
|
|
#[tool(
|
|
name = "todo",
|
|
description = "Session checklist only for work that must continue across turns or survive context compaction. Do not use for work you can finish in the current turn. Omit `todos` to read; provide the full {text, done} list to replace it. Open items let the _Stop hook advise against ending."
|
|
)]
|
|
async fn todo(
|
|
&self,
|
|
Parameters(p): Parameters<todo::TodoParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
match self.todos.handle_todo(p) {
|
|
Ok(text) => todo::text_result(text),
|
|
Err(e) => todo::error_result(format!("Error: {e}")),
|
|
}
|
|
}
|
|
|
|
/// Hook: called by the agent before honoring end_turn. Returns
|
|
/// non-empty objection text iff items remain open.
|
|
#[tool(
|
|
name = "_Stop",
|
|
description = "Returns open todo items if any exist. Used by the agent's _Stop lifecycle hook to advise against ending with incomplete work."
|
|
)]
|
|
async fn stop_hook(
|
|
&self,
|
|
Parameters(_): Parameters<todo::HookParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
todo::text_result(self.todos.stop_objection())
|
|
}
|
|
|
|
/// Hook: called by the agent after context compaction/handoff so the
|
|
/// todo list survives history truncation.
|
|
#[tool(
|
|
name = "_PostCompact",
|
|
description = "Internal hook. Agent invokes after handoff; returns todo state for re-injection."
|
|
)]
|
|
async fn post_compact_hook(
|
|
&self,
|
|
Parameters(_): Parameters<todo::HookParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
todo::text_result(self.todos.post_compact())
|
|
}
|
|
}
|
|
|
|
#[tool_handler(router = self.tool_router)]
|
|
impl ServerHandler for DevMcp {
|
|
fn get_info(&self) -> ServerInfo {
|
|
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
|
.with_server_info(rmcp::model::Implementation::new(
|
|
"buzz-dev-mcp",
|
|
env!("CARGO_PKG_VERSION"),
|
|
))
|
|
.with_instructions(self.state.bootstrap_instructions.clone())
|
|
}
|
|
}
|
|
|
|
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|
let argv0 = std::env::args().next().unwrap_or_default();
|
|
let cmd = Path::new(&argv0)
|
|
.file_stem()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("")
|
|
.to_ascii_lowercase();
|
|
|
|
// Multicall dispatch — sync personalities exit before any runtime is built.
|
|
// No tracing, no tokio, no allocations beyond argv parsing.
|
|
match cmd.as_str() {
|
|
"rg" => std::process::exit(rg::run(std::env::args().skip(1).collect())),
|
|
"tree" => std::process::exit(tree::run(std::env::args().skip(1).collect())),
|
|
_ => {}
|
|
}
|
|
|
|
// Async personalities and MCP server mode — build the runtime.
|
|
tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()?
|
|
.block_on(async_main(cmd))
|
|
}
|
|
|
|
async fn async_main(cmd: String) -> Result<(), Box<dyn std::error::Error>> {
|
|
// HTTPS clients invoked through this MCP process need a Rustls provider;
|
|
// repeated installation is harmless.
|
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
|
|
// buzz CLI needs tokio (async HTTP client).
|
|
if cmd == "buzz" {
|
|
std::process::exit(buzz_cli::run_from_args(std::env::args()).await);
|
|
}
|
|
|
|
// MCP server mode — safe to init tracing now.
|
|
tracing_subscriber::fmt()
|
|
.with_writer(std::io::stderr)
|
|
.with_ansi(false)
|
|
.init();
|
|
|
|
let cwd = std::env::current_dir()?;
|
|
let shim = shim::Shim::install()?;
|
|
let state = Arc::new(shell::SharedState::new(cwd, shim)?);
|
|
|
|
let service = DevMcp::new(state).serve(stdio()).await?;
|
|
service.waiting().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Suppress the console window that Windows otherwise allocates for every
|
|
/// console-subsystem child process spawned from a non-console parent.
|
|
/// No-op on non-Windows platforms.
|
|
pub(crate) fn configure_no_window(cmd: &mut std::process::Command) {
|
|
#[cfg(windows)]
|
|
{
|
|
use std::os::windows::process::CommandExt as _;
|
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
|
}
|
|
#[cfg(not(windows))]
|
|
let _ = cmd;
|
|
}
|
|
|
|
/// Suppress the console window for async (`tokio::process::Command`) spawns.
|
|
/// Equivalent to `configure_no_window` but accepts a tokio command.
|
|
/// No-op on non-Windows platforms.
|
|
pub(crate) fn configure_no_window_async(cmd: &mut tokio::process::Command) {
|
|
#[cfg(windows)]
|
|
{
|
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
|
}
|
|
#[cfg(not(windows))]
|
|
let _ = cmd;
|
|
}
|