diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd3..4d84a537ff8 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -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). diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 0d87bca028c..959e0b3d40a 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -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 { - 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 { + self.authenticate_with_meta(method_id, true).await + } + + async fn authenticate_with_meta( + &mut self, + method_id: &str, + headless: bool, + ) -> Result { + 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 } diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 1352b31cad8..145b08770e9 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -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!( @@ -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", @@ -4768,6 +4778,39 @@ async fn spawn_auth_client(agent: &AuthAgentArgs) -> Result 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 { init_result .get("authMethods") @@ -4776,6 +4819,72 @@ fn extract_auth_methods(init_result: &serde_json::Value) -> Vec Option { + let ids: Vec = 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::>(), + }) + } + + #[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 {