From 9d0ef8440b90bb044d6bab2572c56461f9f4670a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E6=B8=A1=E6=B3=95=E5=B8=AB?= Date: Thu, 14 May 2026 21:42:30 +0000 Subject: [PATCH 1/2] feat: add Grok Build as ACP backend Add xAI Grok Build support via native ACP protocol (`grok agent stdio`). Changes: - Dockerfile.grok: runtime image with Grok Build CLI installed - connection.rs: auto-authenticate when initialize returns authMethods - config.toml.example: add Grok Build config example The authenticate step is triggered automatically when the agent's initialize response includes authMethods (as Grok Build does), so existing backends (Kiro, Claude, Codex, Gemini) are unaffected. Closes #821 --- .github/workflows/build.yml | 3 ++ .github/workflows/docker-smoke-test.yml | 1 + Dockerfile.grok | 40 +++++++++++++++++++++++++ config.toml.example | 6 ++++ src/acp/connection.rs | 35 ++++++++++++++++++++++ 5 files changed, 85 insertions(+) create mode 100644 Dockerfile.grok diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7b360417..50f54fcc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -72,6 +72,7 @@ jobs: - { suffix: "-copilot", dockerfile: "Dockerfile.copilot", artifact: "copilot" } - { suffix: "-opencode", dockerfile: "Dockerfile.opencode", artifact: "opencode" } - { suffix: "-cursor", dockerfile: "Dockerfile.cursor", artifact: "cursor" } + - { suffix: "-grok", dockerfile: "Dockerfile.grok", artifact: "grok" } platform: - { os: linux/amd64, runner: ubuntu-latest } - { os: linux/arm64, runner: ubuntu-24.04-arm } @@ -135,6 +136,7 @@ jobs: - { suffix: "-copilot", artifact: "copilot" } - { suffix: "-opencode", artifact: "opencode" } - { suffix: "-cursor", artifact: "cursor" } + - { suffix: "-grok", artifact: "grok" } runs-on: ubuntu-latest permissions: contents: read @@ -185,6 +187,7 @@ jobs: - { suffix: "-copilot" } - { suffix: "-opencode" } - { suffix: "-cursor" } + - { suffix: "-grok" } runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/docker-smoke-test.yml b/.github/workflows/docker-smoke-test.yml index 64b4653a..91d4312f 100644 --- a/.github/workflows/docker-smoke-test.yml +++ b/.github/workflows/docker-smoke-test.yml @@ -20,6 +20,7 @@ jobs: - { dockerfile: Dockerfile.copilot, suffix: "-copilot", agent: "copilot", agent_args: "--acp" } - { dockerfile: Dockerfile.opencode, suffix: "-opencode", agent: "opencode", agent_args: "acp" } - { dockerfile: Dockerfile.cursor, suffix: "-cursor", agent: "cursor-agent", agent_args: "acp" } + - { dockerfile: Dockerfile.grok, suffix: "-grok", agent: "grok", agent_args: "agent stdio" } runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/Dockerfile.grok b/Dockerfile.grok new file mode 100644 index 00000000..eaed0816 --- /dev/null +++ b/Dockerfile.grok @@ -0,0 +1,40 @@ +# --- Build stage --- +FROM rust:1-bookworm AS builder +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release && rm -rf src +COPY src/ src/ +RUN touch src/main.rs && cargo build --release + +# --- Runtime stage --- +FROM node:22-bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps tini && rm -rf /var/lib/apt/lists/* + +# Install Grok Build CLI as node user (installs to ~/.grok/bin/grok) +# Pin version via ARG; update when upgrading. +ARG GROK_VERSION=0.1.210 +USER node +RUN curl -fsSL https://x.ai/cli/install.sh | bash -s ${GROK_VERSION} +USER root + +# Ensure grok is on PATH for all users +ENV PATH="/home/node/.grok/bin:$PATH" + +# Install gh CLI +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list && \ + apt-get update && apt-get install -y --no-install-recommends gh && \ + rm -rf /var/lib/apt/lists/* + +ENV HOME=/home/node +WORKDIR /home/node + +COPY --from=builder --chown=node:node /build/target/release/openab /usr/local/bin/openab + +USER node +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD pgrep -x openab || exit 1 +ENTRYPOINT ["tini", "--"] +CMD ["openab", "run", "-c", "/etc/openab/config.toml"] diff --git a/config.toml.example b/config.toml.example index d33a0902..7793c64c 100644 --- a/config.toml.example +++ b/config.toml.example @@ -103,6 +103,12 @@ working_dir = "/home/agent" # working_dir = "/home/agent" # env = {} # Auth via: kubectl exec -it -- cursor-agent login +# [agent] +# command = "grok" +# args = ["agent", "stdio"] +# working_dir = "/home/node" +# env = { GROK_CODE_XAI_API_KEY = "${GROK_CODE_XAI_API_KEY}" } + [pool] max_sessions = 10 session_ttl_hours = 24 diff --git a/src/acp/connection.rs b/src/acp/connection.rs index 90c0eae2..18830349 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -446,6 +446,41 @@ impl AcpConnection { load_session = self.supports_load_session, "initialized" ); + + // If the agent requires authentication (e.g. Grok Build), handle it + if let Some(auth_methods) = result.and_then(|r| r.get("authMethods")) { + self.authenticate(auth_methods).await?; + } + Ok(()) + } + + async fn authenticate(&mut self, auth_methods: &Value) -> Result<()> { + let methods: Vec<&str> = auth_methods + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|m| m.get("id").and_then(|id| id.as_str())) + .collect() + }) + .unwrap_or_default(); + + // Prefer API key auth, fall back to cached token + let method_id = if methods.contains(&"xai.api_key") { + "xai.api_key" + } else if methods.contains(&"cached_token") { + "cached_token" + } else { + return Err(anyhow!("no supported auth method (available: {methods:?})")); + }; + + info!(method = method_id, "authenticating"); + let resp = self.send_request( + "authenticate", + Some(json!({"methodId": method_id, "meta": {"headless": true}})), + ) + .await?; + debug!(result = ?resp.result, "authenticate response"); + info!("authenticated"); Ok(()) } From 371400583326afd3f6fb5013e43964f7b3bfbb14 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Mon, 18 May 2026 14:32:06 +0000 Subject: [PATCH 2/2] fix(acp): align Grok authenticate handshake --- src/acp/connection.rs | 92 ++++++++++++++++++++++++++++++++----------- 1 file changed, 69 insertions(+), 23 deletions(-) diff --git a/src/acp/connection.rs b/src/acp/connection.rs index 18830349..c2ab2669 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -74,6 +74,25 @@ fn build_permission_response(params: Option<&Value>) -> Value { } } +fn select_auth_method(auth_methods: &Value) -> Result<&'static str> { + let methods: Vec<&str> = auth_methods + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|m| m.get("id").and_then(|id| id.as_str())) + .collect() + }) + .unwrap_or_default(); + + if methods.contains(&"xai.api_key") && std::env::var_os("GROK_CODE_XAI_API_KEY").is_some() { + return Ok("xai.api_key"); + } + if methods.contains(&"cached_token") { + return Ok("cached_token"); + } + Err(anyhow!("no supported auth method (available: {methods:?})")) +} + fn expand_env(val: &str) -> String { if val.starts_with("${") && val.ends_with('}') { let key = &val[2..val.len() - 1]; @@ -455,30 +474,15 @@ impl AcpConnection { } async fn authenticate(&mut self, auth_methods: &Value) -> Result<()> { - let methods: Vec<&str> = auth_methods - .as_array() - .map(|arr| { - arr.iter() - .filter_map(|m| m.get("id").and_then(|id| id.as_str())) - .collect() - }) - .unwrap_or_default(); - - // Prefer API key auth, fall back to cached token - let method_id = if methods.contains(&"xai.api_key") { - "xai.api_key" - } else if methods.contains(&"cached_token") { - "cached_token" - } else { - return Err(anyhow!("no supported auth method (available: {methods:?})")); - }; + let method_id = select_auth_method(auth_methods)?; info!(method = method_id, "authenticating"); - let resp = self.send_request( - "authenticate", - Some(json!({"methodId": method_id, "meta": {"headless": true}})), - ) - .await?; + let resp = self + .send_request( + "authenticate", + Some(json!({"methodId": method_id, "_meta": {"headless": true}})), + ) + .await?; debug!(result = ?resp.result, "authenticate response"); info!("authenticated"); Ok(()) @@ -698,8 +702,13 @@ impl Drop for AcpConnection { #[cfg(test)] mod tests { - use super::{build_agent_env, build_permission_response, pick_best_option}; + use super::{ + build_agent_env, build_permission_response, pick_best_option, select_auth_method, + }; use serde_json::json; + use std::sync::Mutex; + + static GROK_ENV_LOCK: Mutex<()> = Mutex::new(()); #[test] fn picks_allow_always_over_other_options() { @@ -792,6 +801,43 @@ mod tests { ); } + #[test] + fn auth_method_prefers_api_key_when_env_is_set() { + let _guard = GROK_ENV_LOCK.lock().unwrap(); + std::env::set_var("GROK_CODE_XAI_API_KEY", "test-key"); + let methods = json!([ + {"id": "cached_token"}, + {"id": "xai.api_key"} + ]); + + assert_eq!(select_auth_method(&methods).unwrap(), "xai.api_key"); + std::env::remove_var("GROK_CODE_XAI_API_KEY"); + } + + #[test] + fn auth_method_uses_cached_token_when_api_key_env_is_absent() { + let _guard = GROK_ENV_LOCK.lock().unwrap(); + std::env::remove_var("GROK_CODE_XAI_API_KEY"); + let methods = json!([ + {"id": "xai.api_key"}, + {"id": "cached_token"} + ]); + + assert_eq!(select_auth_method(&methods).unwrap(), "cached_token"); + } + + #[test] + fn auth_method_errors_when_no_usable_method_exists() { + let _guard = GROK_ENV_LOCK.lock().unwrap(); + std::env::remove_var("GROK_CODE_XAI_API_KEY"); + let methods = json!([ + {"id": "xai.api_key"}, + {"id": "unsupported"} + ]); + + assert!(select_auth_method(&methods).is_err()); + } + #[test] fn explicit_env_takes_precedence_over_inherit_env() { let key = "OAB_TEST_PRECEDENCE";