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
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -185,6 +187,7 @@ jobs:
- { suffix: "-copilot" }
- { suffix: "-opencode" }
- { suffix: "-cursor" }
- { suffix: "-grok" }
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/docker-smoke-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions Dockerfile.grok
Original file line number Diff line number Diff line change
@@ -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"]
6 changes: 6 additions & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ working_dir = "/home/agent"
# working_dir = "/home/agent"
# env = {} # Auth via: kubectl exec -it <pod> -- 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
Expand Down
83 changes: 82 additions & 1 deletion src/acp/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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";
Expand Down
Loading