Skip to content
Open
Show file tree
Hide file tree
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
189 changes: 188 additions & 1 deletion crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,20 @@ fn build_client_capabilities() -> serde_json::Value {
})
}

const HERMES_ACP_HOST_ENV: [(&str, &str); 1] = [("HERMES_ACP_SKIP_CONFIGURED_MCP", "1")];

/// Environment defaults required when Buzz owns an ACP runtime process.
///
/// Hermes otherwise starts globally configured MCP servers before it responds
/// to `initialize`. Buzz supplies session MCP servers explicitly through
/// `session/new`, so unrelated global startup must not block this host.
fn acp_host_env_for_agent(agent_command: &str) -> &'static [(&'static str, &'static str)] {
match crate::config::normalize_agent_command_identity(agent_command).as_str() {
"hermes" | "hermes-agent" | "hermes-acp" => &HERMES_ACP_HOST_ENV,
Comment thread
amanning3390 marked this conversation as resolved.
_ => &[],
}
}

impl AcpClient {
/// Kill the agent subprocess and wait for it to exit (no zombies).
///
Expand Down Expand Up @@ -451,12 +465,23 @@ impl AcpClient {
// entry falls through to the standard operator-wins treatment below.
let codex_merge_active = codex_config_value.is_some();

// Apply runtime host defaults before persona values. `Command::env`
// handles keys using native platform semantics (case-insensitive on
// Windows), so a later persona entry overrides the matching default
// without a separate, potentially inconsistent key comparison.
// Inherited parent values still win over both layers.
for &(key, value) in acp_host_env_for_agent(command) {
if std::env::var_os(key).is_none() {
cmd.env(key, value);
}
}

for (key, value) in extra_env {
if key == "CODEX_CONFIG" && codex_merge_active {
// Handled by build_codex_config_env; skip here to avoid double-setting.
continue;
}
if std::env::var(key).is_err() {
if std::env::var_os(key).is_none() {
cmd.env(key, value);
}
}
Expand Down Expand Up @@ -2038,6 +2063,168 @@ fn configure_no_window(cmd: &mut tokio::process::Command) {
mod tests {
use super::*;

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

// This test exercises the host default. An explicit inherited value is
// covered separately and intentionally wins over that default.
if std::env::var_os("HERMES_ACP_SKIP_CONFIGURED_MCP").is_some() {
return;
}

let test_dir =
std::env::temp_dir().join(format!("buzz-acp-hermes-env-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&test_dir).expect("create Hermes env test directory");
let hermes_acp = test_dir.join("hermes-acp");
std::fs::write(
&hermes_acp,
r#"#!/bin/sh
IFS= read -r _request
if [ "${HERMES_ACP_SKIP_CONFIGURED_MCP:-}" != "1" ]; then
echo "missing HERMES_ACP_SKIP_CONFIGURED_MCP=1" >&2
exit 64
fi
printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":2,"agentCapabilities":{},"agentInfo":{"name":"hermes-test","version":"0"}}}'
sleep 1
"#,
)
.expect("write Hermes env test executable");
let mut permissions = std::fs::metadata(&hermes_acp)
.expect("stat Hermes env test executable")
.permissions();
permissions.set_mode(0o700);
std::fs::set_permissions(&hermes_acp, permissions)
.expect("make Hermes env test executable");

let mut client = AcpClient::spawn(
hermes_acp.to_str().expect("test path is valid UTF-8"),
&[],
&[],
false,
)
.await
.expect("spawn Hermes env test executable");
let initialize_result = client.initialize().await;
client.shutdown().await;
std::fs::remove_dir_all(&test_dir).expect("remove Hermes env test directory");

assert!(
initialize_result.is_ok(),
"Buzz-owned Hermes ACP sessions must skip unrelated configured MCP startup; \
initialize result: {initialize_result:?}"
);
}

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

let test_dir =
std::env::temp_dir().join(format!("buzz-acp-hermes-env-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&test_dir).expect("create Hermes env test directory");
let hermes_agent = test_dir.join("hermes-agent");
let expected_key = format!("BUZZ_TEST_EXPECTED_MCP_POLICY_{}", std::process::id());
let script = r#"#!/bin/sh
IFS= read -r _request
if [ "${HERMES_ACP_SKIP_CONFIGURED_MCP-}" != "${__EXPECTED_KEY__-}" ]; then
echo "explicit HERMES_ACP_SKIP_CONFIGURED_MCP policy was overwritten" >&2
exit 64
fi
printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":2,"agentCapabilities":{},"agentInfo":{"name":"hermes-test","version":"0"}}}'
sleep 1
"#
.replace("__EXPECTED_KEY__", &expected_key);
std::fs::write(&hermes_agent, script).expect("write Hermes env test executable");
let mut permissions = std::fs::metadata(&hermes_agent)
.expect("stat Hermes env test executable")
.permissions();
permissions.set_mode(0o700);
std::fs::set_permissions(&hermes_agent, permissions)
.expect("make Hermes env test executable");

let explicit_value = "0";
let expected_value =
std::env::var("HERMES_ACP_SKIP_CONFIGURED_MCP").unwrap_or(explicit_value.into());
let extra_env = vec![
(
"HERMES_ACP_SKIP_CONFIGURED_MCP".to_string(),
explicit_value.to_string(),
),
(expected_key, expected_value),
];
let mut client = AcpClient::spawn(
hermes_agent.to_str().expect("test path is valid UTF-8"),
&[],
&extra_env,
false,
)
.await
.expect("spawn Hermes env test executable");
let initialize_result = client.initialize().await;
client.shutdown().await;
std::fs::remove_dir_all(&test_dir).expect("remove Hermes env test directory");

assert!(
initialize_result.is_ok(),
"an explicit parent or persona MCP policy must override the Buzz host default; \
initialize result: {initialize_result:?}"
);
}

#[test]
fn resolve_preserves_provider_qualified_model_id() {
let result = serde_json::json!({
"models": {
"currentModelId": "openai-codex:gpt-5.6-sol",
"availableModels": [
{
"modelId": "openai-codex:gpt-5.6-luna",
"name": "GPT-5.6 Luna"
}
]
}
});

assert_eq!(
super::resolve_model_switch_method(&result, "openai-codex:gpt-5.6-luna"),
Some(super::ModelSwitchMethod::SetModel {
model_id: "openai-codex:gpt-5.6-luna".to_string(),
})
);
}

#[test]
fn hermes_host_environment_recognizes_supported_command_identities() {
for command in [
"hermes",
"hermes-agent",
"hermes-acp",
"/opt/hermes/bin/hermes-acp",
r"C:\Users\test\bin\HERMES_ACP.EXE",
r"C:\Users\test\AppData\Roaming\npm\hermes-acp.cmd",
r"C:\Tools\Hermes\HERMES-AGENT.BAT",
] {
assert_eq!(
super::acp_host_env_for_agent(command),
&[("HERMES_ACP_SKIP_CONFIGURED_MCP", "1")],
"unexpected ACP host environment for {command}"
);
}
}

#[test]
fn non_hermes_host_environment_is_unchanged() {
for command in ["codex-acp", "claude-agent-acp", "goose", "custom-acp"] {
assert!(
super::acp_host_env_for_agent(command).is_empty(),
"non-Hermes command must not receive Hermes host environment: {command}"
);
}
}

#[test]
fn stop_reason_parses_all_known_values() {
assert_eq!(StopReason::from_str("end_turn"), Some(StopReason::EndTurn));
Expand Down
5 changes: 4 additions & 1 deletion crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,10 @@ pub(crate) fn normalize_agent_command_identity(command: &str) -> String {
.next()
.expect("rsplit always yields at least one element");
let lower = basename.to_ascii_lowercase();
let stem = lower.strip_suffix(".exe").unwrap_or(&lower);
let stem = [".exe", ".cmd", ".bat"]
.iter()
.find_map(|extension| lower.strip_suffix(extension))
.unwrap_or(&lower);
stem.chars()
.map(|character| match character {
' ' | '_' => '-',
Expand Down