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
17 changes: 17 additions & 0 deletions crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,23 @@ 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](https://docs.x.ai/build/overview) speaks ACP over `grok agent stdio`. After `grok login` (subscription / cached token in `~/.grok/auth.json`):

```bash
export BUZZ_PRIVATE_KEY="nsec1..." # the *agent* identity, not a human's
export BUZZ_RELAY_URL="ws://localhost:3000"
export BUZZ_ACP_AGENT_COMMAND="grok"
export BUZZ_ACP_AGENT_ARGS="agent,--always-approve,stdio"

buzz-acp
```

Grok advertises `cached_token` on `initialize`. The harness now sends ACP `authenticate` with `_meta.headless` on spawn so the first `@mention` does not wait for a worker crash/respawn. Goose and Claude do not advertise that method and are unchanged. Do not re-run `authenticate` on every `session/new` — that resets Grok's inner worker.

For CI, set `XAI_API_KEY` instead of (or in addition to) `grok login`; the harness will use `xai.api_key` when that method is advertised.

## Configuration

All configuration is via environment variables (or CLI flags — every env var has a matching flag).
Expand Down
29 changes: 26 additions & 3 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -623,9 +623,32 @@ impl AcpClient {

/// Send the ACP `authenticate` request for an adapter-advertised method.
pub async fn authenticate(&mut self, method_id: &str) -> Result<serde_json::Value, AcpError> {
let params = serde_json::json!({
"methodId": method_id,
});
self.authenticate_with_meta(method_id, false).await
}

/// Headless authenticate. Grok's ACP example requires `_meta.headless`
/// before `session/new`; without it the worker dies with
/// `Auth(AuthorizationRequired)` on the first mention.
pub async fn authenticate_headless(
&mut self,
method_id: &str,
) -> Result<serde_json::Value, AcpError> {
self.authenticate_with_meta(method_id, true).await
}

async fn authenticate_with_meta(
&mut self,
method_id: &str,
headless: bool,
) -> Result<serde_json::Value, AcpError> {
let params = if headless {
serde_json::json!({
"methodId": method_id,
"_meta": { "headless": true },
})
} else {
serde_json::json!({ "methodId": method_id })
};
self.send_request("authenticate", params).await
}

Expand Down
109 changes: 109 additions & 0 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4649,6 +4649,12 @@ async fn initialize_agent_pool(
match initialize_result {
Ok(Ok(init_result)) => {
tracing::info!(agent = i, "agent initialized: {init_result}");
if let Err(e) = authenticate_if_needed(&mut acp, &init_result).await {
tracing::error!(agent = i, "{e}");
acp.shutdown().await;
agent_slots.push(None);
continue;
}
let protocol_version =
init_result["protocolVersion"].as_u64().unwrap_or(1) as u32;
tracing::info!(
Expand Down Expand Up @@ -4742,6 +4748,10 @@ async fn spawn_and_init(
match acp.initialize().await {
Ok(init_result) => {
tracing::info!("agent initialized: {init_result}");
if let Err(e) = authenticate_if_needed(&mut acp, &init_result).await {
acp.shutdown().await;
return Err(e);
}
let protocol_version = init_result["protocolVersion"].as_u64().unwrap_or(1) as u32;
acp.observe(
"agent_initialized",
Expand All @@ -4768,6 +4778,39 @@ async fn spawn_auth_client(agent: &AuthAgentArgs) -> Result<AcpClient, acp::AcpE
AcpClient::spawn(&agent.agent_command, &agent_args, &[], false).await
}

/// Call ACP `authenticate` when the agent advertised a non-interactive method.
///
/// The harness already exposes `buzz-acp authenticate` as a one-shot CLI, but
/// the live pool never sent it. Agents that require auth before `session/new`
/// (Grok's `cached_token`) then fail the first turn. Goose and Claude advertise
/// no such method and skip this path.
async fn authenticate_if_needed(
acp: &mut AcpClient,
init_result: &serde_json::Value,
) -> Result<()> {
let Some(method_id) = preferred_headless_auth_method(init_result) else {
tracing::debug!("ACP headless auth skipped (no cached_token / xai.api_key)");
return Ok(());
};
match tokio::time::timeout(
Duration::from_secs(30),
acp.authenticate_headless(&method_id),
)
.await
{
Ok(Ok(_)) => {
tracing::info!(method_id, "agent authenticated");
Ok(())
}
Ok(Err(e)) => Err(anyhow::anyhow!(
"agent authenticate ({method_id}) failed: {e}"
)),
Err(_) => Err(anyhow::anyhow!(
"agent authenticate ({method_id}) timed out (30s)"
)),
}
}

fn extract_auth_methods(init_result: &serde_json::Value) -> Vec<serde_json::Value> {
init_result
.get("authMethods")
Expand All @@ -4776,6 +4819,72 @@ fn extract_auth_methods(init_result: &serde_json::Value) -> Vec<serde_json::Valu
.unwrap_or_default()
}

/// Pick a non-interactive ACP auth method advertised on `initialize`.
///
/// Prefer `cached_token` (Grok subscription / `~/.grok/auth.json`), then
/// `xai.api_key` when `XAI_API_KEY` is set. Never `grok.com` — that opens a
/// browser. Returns `None` for adapters that do not require authenticate
/// (Goose, Claude) so their session/new path is unchanged.
fn preferred_headless_auth_method(init_result: &serde_json::Value) -> Option<String> {
let ids: Vec<String> = extract_auth_methods(init_result)
.iter()
.filter_map(|method| {
method
.get("id")
.and_then(|id| id.as_str())
.map(str::to_string)
})
.collect();
if ids.iter().any(|id| id == "cached_token") {
return Some("cached_token".into());
}
if std::env::var_os("XAI_API_KEY").is_some() && ids.iter().any(|id| id == "xai.api_key") {
return Some("xai.api_key".into());
}
None
}

#[cfg(test)]
mod headless_auth_method_tests {
use super::preferred_headless_auth_method;

fn init_with_methods(ids: &[&str]) -> serde_json::Value {
serde_json::json!({
"authMethods": ids.iter().map(|id| serde_json::json!({"id": id})).collect::<Vec<_>>(),
})
}

#[test]
fn prefers_cached_token_over_browser_login() {
let init = init_with_methods(&["grok.com", "cached_token"]);
assert_eq!(
preferred_headless_auth_method(&init).as_deref(),
Some("cached_token")
);
}

#[test]
fn skips_when_only_browser_login_is_advertised() {
let init = init_with_methods(&["grok.com"]);
assert_eq!(preferred_headless_auth_method(&init), None);
}

#[test]
fn skips_adapters_with_no_auth_methods() {
let init = serde_json::json!({});
assert_eq!(preferred_headless_auth_method(&init), None);
}

#[test]
fn skips_api_key_when_env_is_unset() {
if std::env::var_os("XAI_API_KEY").is_some() {
return;
}
let init = init_with_methods(&["xai.api_key"]);
assert_eq!(preferred_headless_auth_method(&init), None);
}
}

/// `buzz-acp auth-methods` — spawn an adapter, initialize it, print authMethods.
async fn run_auth_methods(args: AuthMethodsArgs) -> Result<()> {
let mut client = match spawn_auth_client(&args.agent).await {
Expand Down