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..a532da16731 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,22 +920,22 @@ 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 - // Codex and Claude. Treat that legacy fallback as "no args" for zero-arg - // providers so desktop- and env-based launches behave the same way. - if normalized.len() == 1 && normalized[0].eq_ignore_ascii_case("acp") && default_args.is_empty() - { - return default_args; + // Clap's `BUZZ_ACP_AGENT_ARGS` default is the Goose sentinel `acp`. Treat + // that as "no args" whenever this command has a table default so Grok gets + // headless stdio instead of `grok acp`. Goose's own default is `["acp"]`, + // so this is a no-op there; Codex/Claude still collapse it to empty. + if normalized.len() == 1 && normalized[0].eq_ignore_ascii_case("acp") { + return with_grok_managed_stdio(command, default_args); } - normalized + with_grok_managed_stdio(command, normalized) } /// Propagate legacy env-var aliases to their canonical names. @@ -1763,6 +1807,79 @@ mod tests { } } + #[test] + fn default_agent_env_keeps_buzz_auth_in_grok_shell() { + for command in ["grok", "/usr/local/bin/grok", r"C:\Users\test\grok.exe"] { + let env = default_agent_env(command); + assert_eq!(env[0].0, "GROK_CONFIG", "unexpected env key for {command}"); + assert!( + env[0].1.contains("ignore_default_excludes"), + "GROK_CONFIG must disable *KEY* stripping for {command}" + ); + } + } + + #[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("grok", vec!["acp".into()]), + expected, + "Clap's default BUZZ_ACP_AGENT_ARGS=acp must not launch grok acp" + ); + assert_eq!( + normalize_agent_args("/usr/local/bin/grok", vec!["".into()]), + expected + ); + assert_eq!( + normalize_agent_args( + r"C:\Users\test\grok.exe", + vec!["agent".into(), "--always-approve".into(), "stdio".into()] + ), + expected + ); + assert_eq!( + normalize_agent_args("grok", expected.clone()), + expected, + "already-headless argv must stay idempotent" + ); + assert_eq!( + normalize_agent_args( + "grok", + vec!["agent".into(), "--leader".into(), "stdio".into()] + ), + vec!["agent", "--leader", "stdio"], + "explicit --leader must not be rewritten" + ); + } + + #[test] + fn grok_cli_without_agent_args_uses_headless_stdio() { + let args = CliArgs::parse_from([ + "buzz-acp", + "--private-key", + TEST_PRIVATE_KEY, + "--agent-command", + "grok", + ]); + assert_eq!( + args.agent_args, + vec!["acp"], + "clap still defaults BUZZ_ACP_AGENT_ARGS to acp" + ); + let cfg = Config::from_args(args).expect("from_args"); + assert_eq!( + cfg.agent_args, + vec!["agent", "--always-approve", "--no-leader", "stdio"] + ); + } + #[test] fn strips_legacy_acp_arg_case_insensitively() { assert_eq!( diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 531ae335ce5..ff1d04aafee 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -319,15 +319,47 @@ pub fn try_record_agent_command( Ok(default_agent_command()) } +/// 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_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,18 @@ 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; + if normalized.len() == 1 && normalized[0].eq_ignore_ascii_case("acp") { + 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..892879fc751 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -94,6 +94,36 @@ 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("grok", vec!["acp".into()]), 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() {