4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
329 lines
12 KiB
Python
329 lines
12 KiB
Python
"""xAI Grok Build CLI adapter (``grok -p``, non-interactive / headless mode).
|
|
|
|
Grok Build is xAI's terminal-native agentic coding tool (binary: ``grok``). OpenSRE
|
|
uses it purely as a one-shot text responder inside the ReAct loop, so invocations
|
|
run in headless ``-p`` mode with ``--output-format plain`` and never pass
|
|
``--always-approve`` (we do not want Grok autonomously editing files
|
|
or running shell commands; OpenSRE provides its own tools).
|
|
|
|
Env vars
|
|
--------
|
|
GROK_CLI_BIN Optional explicit path to the ``grok`` binary.
|
|
Blank or non-runnable paths are ignored; PATH + fallbacks apply.
|
|
GROK_CLI_MODEL Optional model override (e.g. ``grok-build-0.1``).
|
|
Unset or empty → omit ``-m``; the CLI's configured default applies.
|
|
GROK_CLI_TIMEOUT_SECONDS Optional invocation timeout override in seconds for long prompts
|
|
(default: 300, min: 30, max: 600).
|
|
XAI_API_KEY API-key auth for headless/CI runs. Forwarded explicitly to the
|
|
Grok subprocess via ``CLIInvocation.env`` (see Auth below).
|
|
|
|
Auth
|
|
----
|
|
Grok resolves credentials in the order ``model.api_key > model.env_key > active
|
|
session token > XAI_API_KEY`` (https://docs.x.ai/build/cli/headless-scripting).
|
|
``XAI_API_KEY`` is a secret, so it is forwarded **only** to the Grok subprocess via
|
|
``CLIInvocation.env`` rather than the blanket ``_SAFE_SUBPROCESS_ENV_PREFIXES``
|
|
allowlist (which would leak it into every other CLI subprocess — same rationale as
|
|
the Copilot/Claude Code adapters). There is no documented ``grok auth status``
|
|
command, so auth is detected via ``grok models`` — a fast (~0.5 s) subcommand that
|
|
prints "You are logged in" on success and doesn't incur an LLM call. Exit 0 with a
|
|
login confirmation string → authenticated; auth-error strings in output → not
|
|
authenticated; network/timeout → unclear. ``XAI_API_KEY`` in env is treated as
|
|
an authenticated fallback for headless/CI runs even when the probe result is unclear.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from integrations.llm_cli.base import CLIInvocation, CLIProbe
|
|
from integrations.llm_cli.binary_resolver import (
|
|
candidate_binary_names as _candidate_binary_names,
|
|
)
|
|
from integrations.llm_cli.binary_resolver import (
|
|
default_cli_fallback_paths as _default_cli_fallback_paths,
|
|
)
|
|
from integrations.llm_cli.binary_resolver import (
|
|
resolve_cli_binary,
|
|
)
|
|
from integrations.llm_cli.constants import (
|
|
DEFAULT_EXEC_TIMEOUT_SEC as _DEFAULT_EXEC_TIMEOUT_SEC,
|
|
)
|
|
from integrations.llm_cli.constants import (
|
|
MAX_EXEC_TIMEOUT_SEC as _MAX_EXEC_TIMEOUT_SEC,
|
|
)
|
|
from integrations.llm_cli.constants import (
|
|
MIN_EXEC_TIMEOUT_SEC as _MIN_EXEC_TIMEOUT_SEC,
|
|
)
|
|
from integrations.llm_cli.env_overrides import (
|
|
XAI_CLI_ENV_KEYS,
|
|
nonempty_env_values,
|
|
)
|
|
from integrations.llm_cli.probe_utils import run_version_probe
|
|
from integrations.llm_cli.semver_utils import parse_semver_three_part
|
|
from integrations.llm_cli.timeout_utils import resolve_timeout_from_env
|
|
|
|
_PROBE_TIMEOUT_SEC = 5.0
|
|
_AUTH_PROBE_TIMEOUT_SEC = 10.0
|
|
_AUTH_HINT = "Run: grok login or set XAI_API_KEY."
|
|
|
|
|
|
def _resolve_exec_timeout_seconds() -> float:
|
|
return resolve_timeout_from_env(
|
|
env_key="GROK_CLI_TIMEOUT_SECONDS",
|
|
default=_DEFAULT_EXEC_TIMEOUT_SEC,
|
|
minimum=_MIN_EXEC_TIMEOUT_SEC,
|
|
maximum=_MAX_EXEC_TIMEOUT_SEC,
|
|
)
|
|
|
|
|
|
def _grok_env_overrides() -> dict[str, str]:
|
|
"""Subprocess env overrides: disable color and forward xAI API credentials."""
|
|
env: dict[str, str] = {"NO_COLOR": "1"}
|
|
env.update(nonempty_env_values(XAI_CLI_ENV_KEYS))
|
|
return env
|
|
|
|
|
|
def _has_explicit_grok_auth_env() -> str | None:
|
|
"""Return the env var name if an explicit xAI API credential is set, else None."""
|
|
if os.environ.get("XAI_API_KEY", "").strip():
|
|
return "XAI_API_KEY"
|
|
return None
|
|
|
|
|
|
def _classify_grok_auth_from_probe(
|
|
returncode: int, stdout: str, stderr: str
|
|
) -> tuple[bool | None, str]:
|
|
"""Classify auth state from a ``grok models`` probe result.
|
|
|
|
``grok models`` is fast (~0.5 s) and prints "You are logged in" on success,
|
|
making it a reliable auth probe without incurring an LLM call.
|
|
Mirrors the pattern used by the Antigravity CLI adapter.
|
|
"""
|
|
text = (stdout + "\n" + stderr).lower()
|
|
if "you are logged in" in text or "logged in with" in text:
|
|
return True, "Authenticated via Grok Build CLI (grok models)."
|
|
if "unauthorized" in text or "not logged in" in text or "please log in" in text:
|
|
return False, f"Not authenticated. {_AUTH_HINT}"
|
|
if "401" in text or ("api key" in text and ("invalid" in text or "missing" in text)):
|
|
return False, f"Authentication failed. {_AUTH_HINT}"
|
|
if returncode == 0:
|
|
return True, "Authenticated via Grok Build CLI."
|
|
if "network" in text or "timeout" in text or "unreachable" in text or "connection" in text:
|
|
return None, "Network error during Grok auth probe; will retry at invocation."
|
|
tail = (stderr or stdout).strip()[:200]
|
|
if tail:
|
|
return None, f"Auth status unclear (exit {returncode}): {tail}"
|
|
return None, f"Auth status unclear (exit {returncode})."
|
|
|
|
|
|
def parse_grok_models_output(text: str) -> list[str]:
|
|
"""Parse ``grok models`` stdout into an ordered list of model IDs.
|
|
|
|
Example output::
|
|
|
|
You are logged in with grok.com.
|
|
|
|
Default model: grok-build
|
|
|
|
Available models:
|
|
- grok-composer-2.5-fast
|
|
* grok-build (default)
|
|
|
|
Returns model IDs in the order listed, default model last (it has ``*``).
|
|
"""
|
|
models: list[str] = []
|
|
in_section = False
|
|
for line in text.splitlines():
|
|
stripped = line.strip()
|
|
if stripped.lower().startswith("available models"):
|
|
in_section = True
|
|
continue
|
|
if in_section:
|
|
if stripped.startswith(("- ", "* ")):
|
|
model_id = stripped[2:].split("(")[0].strip()
|
|
if model_id:
|
|
models.append(model_id)
|
|
elif stripped and not stripped.startswith(("-", "*")):
|
|
break
|
|
return models
|
|
|
|
|
|
def _fallback_grok_paths() -> list[str]:
|
|
return _default_cli_fallback_paths("grok")
|
|
|
|
|
|
class GrokCLIAdapter:
|
|
"""Non-interactive xAI Grok Build CLI (``grok -p``, headless mode, no TTY)."""
|
|
|
|
name = "grok-cli"
|
|
binary_env_key = "GROK_CLI_BIN"
|
|
install_hint = "curl -fsSL https://x.ai/cli/install.sh | bash"
|
|
auth_hint = _AUTH_HINT.removesuffix(".")
|
|
min_version: str | None = None
|
|
default_exec_timeout_sec = _DEFAULT_EXEC_TIMEOUT_SEC
|
|
|
|
def _resolve_binary(self) -> str | None:
|
|
return resolve_cli_binary(
|
|
explicit_env_key="GROK_CLI_BIN",
|
|
binary_names=_candidate_binary_names("grok"),
|
|
fallback_paths=_fallback_grok_paths,
|
|
)
|
|
|
|
def _probe_binary(self, binary_path: str) -> CLIProbe:
|
|
version_output, version_error = run_version_probe(
|
|
binary_path,
|
|
timeout_sec=_PROBE_TIMEOUT_SEC,
|
|
)
|
|
if version_error:
|
|
return CLIProbe(
|
|
installed=False,
|
|
version=None,
|
|
logged_in=None,
|
|
bin_path=None,
|
|
detail=version_error,
|
|
)
|
|
|
|
version = parse_semver_three_part(version_output or "")
|
|
# Use the full parent-process env for the probe so grok can reach its
|
|
# keyring / session credentials (DBUS_SESSION_BUS_ADDRESS, DISPLAY, etc.
|
|
# are stripped by build_cli_subprocess_env and cause the process to hang).
|
|
# Merge overrides last so NO_COLOR and XAI_API_KEY always take effect.
|
|
probe_env = {**os.environ, **_grok_env_overrides()}
|
|
|
|
try:
|
|
auth_proc = subprocess.run(
|
|
[binary_path, "models"],
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=_AUTH_PROBE_TIMEOUT_SEC,
|
|
check=False,
|
|
env=probe_env,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
logged_in: bool | None = None
|
|
auth_detail = (
|
|
f"Grok auth probe timed out after {_AUTH_PROBE_TIMEOUT_SEC:.0f}s; "
|
|
"auth status unknown."
|
|
)
|
|
except OSError as exc:
|
|
logged_in = None
|
|
auth_detail = f"Could not spawn grok for auth probe: {exc}"
|
|
else:
|
|
logged_in, auth_detail = _classify_grok_auth_from_probe(
|
|
auth_proc.returncode, auth_proc.stdout, auth_proc.stderr
|
|
)
|
|
|
|
# XAI_API_KEY is definitive for headless/CI auth when the probe result is
|
|
# *unclear* (network error, timeout). Do NOT promote when the probe
|
|
# explicitly returned False — XAI_API_KEY may itself be the rejected
|
|
# credential, and masking that defers the failure to invocation time.
|
|
auth_env_key = _has_explicit_grok_auth_env()
|
|
if logged_in is None and auth_env_key:
|
|
logged_in = True
|
|
auth_detail = f"Authenticated via {auth_env_key}."
|
|
|
|
return CLIProbe(
|
|
installed=True,
|
|
version=version,
|
|
logged_in=logged_in,
|
|
bin_path=binary_path,
|
|
detail=auth_detail,
|
|
)
|
|
|
|
def detect(self) -> CLIProbe:
|
|
binary = self._resolve_binary()
|
|
if not binary:
|
|
return CLIProbe(
|
|
installed=False,
|
|
version=None,
|
|
logged_in=None,
|
|
bin_path=None,
|
|
detail=(
|
|
"Grok Build CLI not found on PATH or known install locations. "
|
|
f"Install with: {self.install_hint} or set GROK_CLI_BIN."
|
|
),
|
|
)
|
|
return self._probe_binary(binary)
|
|
|
|
def build(
|
|
self,
|
|
*,
|
|
prompt: str,
|
|
model: str | None,
|
|
workspace: str,
|
|
reasoning_effort: str | None = None,
|
|
) -> CLIInvocation:
|
|
# Grok Build headless mode does not expose a reasoning-effort flag; the
|
|
# parameter is accepted for protocol compatibility and ignored.
|
|
_ = reasoning_effort
|
|
binary = self._resolve_binary()
|
|
if not binary:
|
|
raise RuntimeError(
|
|
f"Grok Build CLI not found. {self.install_hint}"
|
|
" or set GROK_CLI_BIN to the full binary path."
|
|
)
|
|
|
|
ws = (workspace or "").strip()
|
|
cwd = str(Path(ws).expanduser()) if ws else os.getcwd()
|
|
|
|
# `grok -p PROMPT` runs a single headless turn (no TTY). `--output-format
|
|
# plain` yields the model's text answer for parse(). We deliberately omit
|
|
# `--always-approve` so Grok never auto-executes its own tools — OpenSRE
|
|
# drives tool use itself.
|
|
argv: list[str] = [
|
|
binary,
|
|
"-p",
|
|
prompt,
|
|
"--output-format",
|
|
"plain",
|
|
]
|
|
|
|
resolved_model = (model or "").strip()
|
|
if resolved_model:
|
|
argv.extend(["-m", resolved_model])
|
|
|
|
# Forward xAI credentials explicitly rather than via the blanket prefix
|
|
# allowlist, so XAI_API_KEY does not leak into other CLI adapters.
|
|
env = _grok_env_overrides()
|
|
|
|
return CLIInvocation(
|
|
argv=tuple(argv),
|
|
stdin=None,
|
|
cwd=cwd,
|
|
env=env,
|
|
timeout_sec=_resolve_exec_timeout_seconds(),
|
|
)
|
|
|
|
def parse(self, *, stdout: str, stderr: str, returncode: int) -> str:
|
|
result = (stdout or "").strip()
|
|
if not result:
|
|
raise RuntimeError(
|
|
self.explain_failure(stdout=stdout, stderr=stderr, returncode=returncode)
|
|
+ " (empty output)"
|
|
)
|
|
return result
|
|
|
|
def explain_failure(self, *, stdout: str, stderr: str, returncode: int) -> str:
|
|
from integrations.llm_cli.failure_explain import explain_cli_failure
|
|
|
|
# Provider-specific auth message (more actionable than the generic shared
|
|
# hint); quota / context-length / network classification comes from the
|
|
# shared helper so Grok stays consistent with the other CLI adapters.
|
|
lowered = f"{stderr}\n{stdout}".lower()
|
|
extra: tuple[str, ...] = ()
|
|
if "unauthorized" in lowered or "401" in lowered or "not logged in" in lowered:
|
|
extra = (f"Authentication failed. {_AUTH_HINT}",)
|
|
|
|
return explain_cli_failure(
|
|
exit_label="grok -p",
|
|
stdout=stdout,
|
|
stderr=stderr,
|
|
returncode=returncode,
|
|
extra_messages=extra,
|
|
)
|