From 9fac1e0f7941637bfe7a41edae0e02ff46f48a6a Mon Sep 17 00:00:00 2001 From: Trevor P Date: Sun, 6 Sep 2026 21:54:45 +1000 Subject: [PATCH 1/2] fix(acp): keep Grok managed agents on headless stdio with Buzz auth Empty grok argv launched the interactive TUI (ENXIO under Desktop). Catalog argv without --no-leader shared the TUI leader socket when [cli] use_leader is on. grokShell also dropped BUZZ_PRIVATE_KEY via the *KEY* denylist, so buzz messages send failed and the model hunted /proc. Default grok argv to agent --always-approve --no-leader stdio, insert --no-leader on existing ACP argv unless --leader is explicit, and inject GROK_CONFIG so grokShell keeps harness auth env. Some grok security gates still read ~/.grok/config.toml; README documents the operator disk fallback. Related: #3457 #4224 Signed-off-by: Trevor P --- crates/buzz-acp/README.md | 35 +++++++ crates/buzz-acp/src/config.rs | 99 ++++++++++++++++++- .../src-tauri/src/managed_agents/discovery.rs | 40 +++++++- .../src/managed_agents/discovery/presets.rs | 24 ++++- .../src/managed_agents/discovery/tests.rs | 29 ++++++ 5 files changed, 218 insertions(+), 9 deletions(-) diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index 41d9a214bdd..d2db9153eac 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -98,6 +98,41 @@ buzz-acp Older installs that still expose `claude-code-acp` are also supported. `buzz-acp` treats both Claude ACP command names as the same zero-arg runtime. +## Running with Grok Build + +Grok Build speaks ACP natively over stdio. Empty `BUZZ_ACP_AGENT_ARGS` must not +launch the interactive TUI (that fails with `ENXIO` under Desktop). The harness +defaults to headless ACP and keeps grokShell from dropping `BUZZ_PRIVATE_KEY` +(`*KEY*` matches Grok's default env denylist). + +```bash +# Install: https://build.x.ai/docs (binary often lands in ~/.grok/bin) +grok login # or export XAI_API_KEY=... + +export BUZZ_ACP_AGENT_COMMAND="grok" +# Optional — empty args resolve to the same argv: +# export BUZZ_ACP_AGENT_ARGS="agent,--always-approve,--no-leader,stdio" + +buzz-acp +``` + +`--no-leader` keeps a managed agent off the interactive TUI leader socket when +`[cli] use_leader` is on. An explicit `--leader` in `BUZZ_ACP_AGENT_ARGS` is +left alone. + +`GROK_CONFIG` is injected with `shell_environment_policy.ignore_default_excludes=true` +so `buzz messages send` can sign. Some Grok security gates read +`~/.grok/config.toml` instead of that overlay; if grokShell still reports +`BUZZ_PRIVATE_KEY is required`, pin the same table on disk: + +```toml +[shell_environment_policy] +inherit = "all" +ignore_default_excludes = true +``` + +That disk table is operator-local. It is not a Buzz config file. + ## Configuration All configuration is via environment variables (or CLI flags — every env var has a matching flag). diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index b4d27903c62..939af5ecbba 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -787,15 +787,48 @@ pub(crate) fn normalize_agent_command_identity(command: &str) -> String { .collect() } +/// Managed Grok Build ACP argv. `--no-leader` keeps the subprocess off the +/// interactive TUI leader socket when `[cli] use_leader` is on. +const GROK_ACP_ARGS: &[&str] = &["agent", "--always-approve", "--no-leader", "stdio"]; + +fn grok_acp_args() -> Vec { + GROK_ACP_ARGS.iter().map(|arg| (*arg).to_string()).collect() +} + fn default_agent_args(command: &str) -> Option> { match normalize_agent_command_identity(command).as_str() { "goose" => Some(vec!["acp".to_string()]), + "grok" => Some(grok_acp_args()), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, } } +/// Insert `--no-leader` for Grok ACP argv that omitted it. Leaves an explicit +/// `--leader` or existing `--no-leader` alone so operators can still share a +/// leader on purpose. Does not rewrite stored persona JSON. +fn with_grok_managed_stdio(command: &str, mut args: Vec) -> Vec { + if normalize_agent_command_identity(command) != "grok" { + return args; + } + if args + .iter() + .any(|arg| arg == "--no-leader" || arg == "--leader") + { + return args; + } + if let Some(index) = args.iter().position(|arg| arg == "--always-approve") { + args.insert(index + 1, "--no-leader".to_string()); + return args; + } + if let Some(index) = args.iter().position(|arg| arg == "agent") { + args.insert(index + 1, "--no-leader".to_string()); + return args; + } + args +} + /// Per-runtime environment defaults applied when Buzz owns the agent process. /// /// Mirrors [`default_agent_args`]: keyed on the normalized command identity, @@ -808,9 +841,20 @@ fn default_agent_args(command: &str) -> Option> { /// 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. +/// Grok's bash tool drops `*KEY*` / `*SECRET*` / `*TOKEN*` unless this overlay +/// turns default excludes off. `BUZZ_PRIVATE_KEY` matches `*KEY*`, so grokShell +/// cannot run `buzz messages send` and the model scrapes `/proc` for the parent +/// env. Overlay-allowlisted filter fields only — it cannot inject secret values +/// (those already sit on the grok process). Some grok security gates read +/// `~/.grok/config.toml` instead of this overlay; operators can pin the same +/// table on disk if bash still strips the key. +const GROK_SHELL_KEEP_BUZZ_AUTH: &str = + r#"{"shell_environment_policy":{"inherit":"all","ignore_default_excludes":true}}"#; + 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")], + "grok" => &[("GROK_CONFIG", GROK_SHELL_KEEP_BUZZ_AUTH)], _ => &[], } } @@ -876,11 +920,11 @@ pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec>(); let Some(default_args) = default_agent_args(command) else { - return normalized; + return with_grok_managed_stdio(command, normalized); }; if normalized.is_empty() { - return default_args; + return with_grok_managed_stdio(command, default_args); } // Older callers relied on the Goose-specific default even for runtimes like @@ -888,10 +932,10 @@ pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec Vec { + GROK_ACP_ARGS.iter().map(|arg| (*arg).to_string()).collect() +} + fn default_agent_args(command: &str) -> Option> { match normalize_command_identity(command).as_str() { "goose" => Some(vec!["acp".to_string()]), + "grok" => Some(grok_acp_args()), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, } } +/// Insert `--no-leader` for Grok ACP argv that omitted it. Leaves an explicit +/// `--leader` or existing `--no-leader` alone. Does not rewrite stored JSON. +fn with_grok_managed_stdio(command: &str, mut args: Vec) -> Vec { + if normalize_command_identity(command) != "grok" { + return args; + } + if args + .iter() + .any(|arg| arg == "--no-leader" || arg == "--leader") + { + return args; + } + if let Some(index) = args.iter().position(|arg| arg == "--always-approve") { + args.insert(index + 1, "--no-leader".to_string()); + return args; + } + if let Some(index) = args.iter().position(|arg| arg == "agent") { + args.insert(index + 1, "--no-leader".to_string()); + return args; + } + args +} + pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec { let normalized = agent_args .into_iter() @@ -336,19 +368,19 @@ pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec>(); let Some(default_args) = default_agent_args(command) else { - return normalized; + return with_grok_managed_stdio(command, normalized); }; if normalized.is_empty() { - return default_args; + return with_grok_managed_stdio(command, default_args); } if normalized.len() == 1 && normalized[0].eq_ignore_ascii_case("acp") && default_args.is_empty() { - return default_args; + return with_grok_managed_stdio(command, default_args); } - normalized + with_grok_managed_stdio(command, normalized) } fn profile_target_dirs(root: &Path) -> [PathBuf; 2] { diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index b184559c157..12648f7e022 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -156,7 +156,7 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[ id: "grok", label: "Grok Build", command: "grok", - args: &["agent", "--always-approve", "stdio"], + args: &["agent", "--always-approve", "--no-leader", "stdio"], install_instructions_url: "https://build.x.ai/docs", install_hint: "Buzz talks to Grok Build through its CLI's agent stdio mode.", underlying_cli: None, @@ -344,6 +344,28 @@ mod tests { underlying_cli_install_instructions_url: Some("https://example.com/amp"), }; + #[test] + fn grok_preset_uses_headless_stdio_without_leader() { + let preset = PRESET_HARNESSES + .iter() + .find(|preset| preset.id == "grok") + .expect("Grok Build preset should be present"); + + assert_eq!(preset.command, "grok"); + assert_eq!( + preset.args, + &["agent", "--always-approve", "--no-leader", "stdio"] + ); + + let entry = preset_catalog_entry(preset, |command| { + (command == "grok").then(|| PathBuf::from("/usr/local/bin/grok")) + }); + assert_eq!( + entry.default_args, + vec!["agent", "--always-approve", "--no-leader", "stdio"] + ); + } + #[test] fn devin_preset_uses_official_native_acp_invocation() { let preset = PRESET_HARNESSES diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index dc155d82f5b..c89135b09fa 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -94,6 +94,35 @@ fn normalizes_buzz_agent_args_to_empty() { ); } +#[test] +fn normalizes_grok_args_to_headless_stdio_without_leader() { + let expected = vec![ + "agent".to_string(), + "--always-approve".to_string(), + "--no-leader".to_string(), + "stdio".to_string(), + ]; + assert_eq!(normalize_agent_args("grok", Vec::new()), expected); + assert_eq!( + normalize_agent_args("/usr/local/bin/grok", Vec::new()), + expected + ); + assert_eq!( + normalize_agent_args( + "grok", + vec!["agent".into(), "--always-approve".into(), "stdio".into()] + ), + expected + ); + assert_eq!( + normalize_agent_args( + "grok", + vec!["agent".into(), "--leader".into(), "stdio".into()] + ), + vec!["agent", "--leader", "stdio"] + ); +} + #[cfg(unix)] #[test] fn explicit_path_resolution_ignores_non_executable_files() { From 935c21316293d19f25d16d9dd084fa1852279587 Mon Sep 17 00:00:00 2001 From: Trevor P Date: Sun, 6 Sep 2026 22:35:34 +1000 Subject: [PATCH 2/2] fix(acp): treat clap's default acp sentinel as empty for Grok BUZZ_ACP_AGENT_ARGS defaults to acp. That blocked Grok's headless stdio default and launched grok acp. Collapse the sentinel whenever the command has a table default, and cover Config::from_args. Signed-off-by: Trevor P --- crates/buzz-acp/src/config.rs | 36 ++++++++++++++++--- .../src-tauri/src/managed_agents/discovery.rs | 3 +- .../src/managed_agents/discovery/tests.rs | 1 + 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 939af5ecbba..a532da16731 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -927,11 +927,11 @@ pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec) -> Vec