Skip to content
Merged
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
84 changes: 83 additions & 1 deletion crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -494,12 +494,22 @@ impl AcpClient {
// entry falls through to the standard operator-wins treatment below.
let codex_merge_active = codex_config_value.is_some();

// Per-runtime environment defaults (e.g. Hermes MCP-startup isolation).
// Applied first so both persona `extra_env` (below, via `Command::env`
// key replacement) and inherited parent env (via the parent-presence
// check) override them.
for &(key, value) in crate::config::default_agent_env(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 @@ -2891,6 +2901,78 @@ mod tests {
);
}

/// Spawn a probe script whose file name carries a runtime identity (e.g.
/// `hermes-acp`) and return the value of `var` as the child observed it.
/// `<unset>` means the child did not receive the var.
#[cfg(unix)]
async fn spawn_named_and_read_child_env(
file_name: &str,
var: &str,
extra_env: &[(String, String)],
) -> String {
use std::os::unix::fs::PermissionsExt;

let dir = std::env::temp_dir().join(format!("buzz-acp-env-probe-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).expect("create env probe dir");
let path = dir.join(file_name);
std::fs::write(
&path,
format!("#!/bin/sh\nprintf '%s\\n' \"${{{var}:-<unset>}}\"\n"),
)
.expect("write env probe script");
let mut permissions = std::fs::metadata(&path).expect("stat probe").permissions();
permissions.set_mode(0o700);
std::fs::set_permissions(&path, permissions).expect("chmod probe");

let mut client = AcpClient::spawn(
path.to_str().expect("probe path is UTF-8"),
&[],
extra_env,
false,
)
.await
.expect("spawn env probe script");
let observed = client
.reader
.next()
.await
.unwrap_or_else(|| panic!("child produced no output for {var}"))
.expect("child stdout was not readable");
client.shutdown().await;
std::fs::remove_dir_all(&dir).expect("remove env probe dir");
observed
}

/// Buzz-owned Hermes processes get the configured-MCP isolation default,
/// and an explicit persona entry still overrides it (defaults are applied
/// before `extra_env`, so the later `Command::env` write wins).
#[cfg(unix)]
#[tokio::test]
async fn spawn_applies_runtime_env_defaults_with_extra_env_precedence() {
const VAR: &str = "HERMES_ACP_SKIP_CONFIGURED_MCP";
if std::env::var_os(VAR).is_some() {
// Inherited parent values win over both layers; the default and
// override behavior below is unobservable in such an environment.
return;
}

assert_eq!(
spawn_named_and_read_child_env("hermes-acp", VAR, &[]).await,
"1",
"Hermes spawns must default {VAR}=1"
);
assert_eq!(
spawn_named_and_read_child_env("hermes-acp", VAR, &[(VAR.into(), "0".into())]).await,
"0",
"an explicit extra_env entry must override the runtime default"
);
assert_eq!(
spawn_named_and_read_child_env("other-agent", VAR, &[]).await,
"<unset>",
"non-Hermes spawns must not receive Hermes defaults"
);
}

/// Persona config must not be able to re-enable the scheduler: this is a
/// correctness invariant, not an operator-tunable default, so the
/// injection is set after (and therefore wins over) the `extra_env` loop.
Expand Down
59 changes: 58 additions & 1 deletion crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,12 @@ 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);
// Windows resolves commands through `.exe` binaries and npm's `.cmd`/`.bat`
// shims; all three name the same runtime identity.
let stem = [".exe", ".cmd", ".bat"]
.iter()
.find_map(|extension| lower.strip_suffix(extension))
.unwrap_or(&lower);
stem.chars()
.map(|character| match character {
' ' | '_' => '-',
Expand All @@ -694,6 +699,25 @@ fn default_agent_args(command: &str) -> Option<Vec<String>> {
}
}

/// Per-runtime environment defaults applied when Buzz owns the agent process.
///
/// Mirrors [`default_agent_args`]: keyed on the normalized command identity,
/// with the merge (in `AcpClient::spawn`) giving explicit persona env and
/// inherited parent env precedence over these defaults.
///
/// Hermes: ACP hosts supply session MCP servers explicitly through
/// `session/new`, but Hermes otherwise starts every profile-configured MCP
/// server before it responds to `initialize` — which can exhaust the host's
/// startup budget (see block/buzz#3355). Skip that unrelated global startup
/// by default; an operator or persona can still opt back in by setting the
/// variable explicitly.
pub(crate) fn default_agent_env(command: &str) -> &'static [(&'static str, &'static str)] {
match normalize_agent_command_identity(command).as_str() {
"hermes" | "hermes-agent" | "hermes-acp" => &[("HERMES_ACP_SKIP_CONFIGURED_MCP", "1")],
_ => &[],
}
}

/// Build the `CODEX_CONFIG` environment variable that enables full outbound
/// network access in Codex's macOS Seatbelt sandbox.
///
Expand Down Expand Up @@ -1589,6 +1613,15 @@ mod tests {
"claude-code"
);
assert_eq!(normalize_agent_command_identity("Goose.EXE"), "goose");
// Windows npm shims resolve to `.cmd`/`.bat` wrappers.
assert_eq!(
normalize_agent_command_identity(r"C:\Users\test\AppData\Roaming\npm\hermes-acp.cmd"),
"hermes-acp"
);
assert_eq!(
normalize_agent_command_identity(r"C:\Tools\Hermes\HERMES-AGENT.BAT"),
"hermes-agent"
);
// Non-ASCII must not panic.
assert_eq!(normalize_agent_command_identity("my-agënt"), "my-agënt");
// Edge cases: empty, whitespace-only, bare separators.
Expand All @@ -1598,6 +1631,30 @@ mod tests {
assert_eq!(normalize_agent_command_identity("///"), "");
}

#[test]
fn default_agent_env_recognizes_hermes_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",
] {
assert_eq!(
default_agent_env(command),
&[("HERMES_ACP_SKIP_CONFIGURED_MCP", "1")],
"unexpected env defaults for {command}"
);
}
for command in ["goose", "codex-acp", "claude-agent-acp", "buzz-agent", ""] {
assert!(
default_agent_env(command).is_empty(),
"non-Hermes command must have no env defaults: {command}"
);
}
}

#[test]
fn strips_legacy_acp_arg_case_insensitively() {
assert_eq!(
Expand Down
Loading