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
35 changes: 35 additions & 0 deletions crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
135 changes: 126 additions & 9 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
GROK_ACP_ARGS.iter().map(|arg| (*arg).to_string()).collect()
}

fn default_agent_args(command: &str) -> Option<Vec<String>> {
match normalize_agent_command_identity(command).as_str() {
"goose" => Some(vec!["acp".to_string()]),
"grok" => Some(grok_acp_args()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat the legacy acp parser default as empty for Grok

When BUZZ_ACP_AGENT_COMMAND=grok is set without BUZZ_ACP_AGENT_ARGS—the documented standalone quick start—Clap supplies the existing default agent_args = ["acp"], so this Grok default is never selected: normalize_agent_args returns ["acp"] rather than the headless stdio argv. Thus buzz-acp, models, and authentication helpers invoke grok acp, leaving the advertised no-args workflow unfixed; handle the legacy acp sentinel for Grok and cover the parsed CLI path rather than testing only a manually constructed empty vector.

AGENTS.md reference: AGENTS.md:L188-L192

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 935c213.

Clap still defaults BUZZ_ACP_AGENT_ARGS to acp. normalize_agent_args now treats that sentinel as empty whenever the command has a table default, so BUZZ_ACP_AGENT_COMMAND=grok without args resolves to agent --always-approve --no-leader stdio instead of grok acp.

grok_cli_without_agent_args_uses_headless_stdio covers CliArgs::parse_fromConfig::from_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<String>) -> Vec<String> {
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,
Expand All @@ -808,9 +841,20 @@ fn default_agent_args(command: &str) -> Option<Vec<String>> {
/// 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)],
_ => &[],
}
}
Expand Down Expand Up @@ -876,22 +920,22 @@ pub fn normalize_agent_args(command: &str, agent_args: Vec<String>) -> Vec<Strin
.collect::<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.
Expand Down Expand Up @@ -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!(
Expand Down
43 changes: 37 additions & 6 deletions desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
GROK_ACP_ARGS.iter().map(|arg| (*arg).to_string()).collect()
}

fn default_agent_args(command: &str) -> Option<Vec<String>> {
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<String>) -> Vec<String> {
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<String>) -> Vec<String> {
let normalized = agent_args
.into_iter()
Expand All @@ -336,19 +368,18 @@ pub fn normalize_agent_args(command: &str, agent_args: Vec<String>) -> Vec<Strin
.collect::<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] {
Expand Down
24 changes: 23 additions & 1 deletion desktop/src-tauri/src/managed_agents/discovery/presets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions desktop/src-tauri/src/managed_agents/discovery/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down