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..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]; @@ -446,6 +465,26 @@ 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 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?; + debug!(result = ?resp.result, "authenticate response"); + info!("authenticated"); Ok(()) } @@ -663,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() { @@ -757,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";