mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(grok-cli): GrokCliProvider — subscription auth mount, mirrors ClaudeCodeProvider
Replace the opencode GrokProvider with GrokCliProvider: reuses the orchestrator's shared mount/auth/git assembly (gateway + identity) exactly like the Claude path, mounts the host ~/.grok/auth.json read-only (SuperGrok subscription) instead of injecting an xAI key, and sets the slim env the grok-cli entrypoint + renderer read (ROBOCO_AGENT_ID for per-role flags, model, mcp-config, prompt). Provider routing fields are blanked before the shared step so the grok endpoint is never mislabelled ANTHROPIC_*. Per-role permission logic now lives in grok_cli_config, so the provider is slim. Registry/orchestrator/exports updated; provider tests rewritten for the CLI behavior (no XAI key, auth mount present/absent).
This commit is contained in:
@@ -8,18 +8,19 @@ and falls back to its built-in Claude Code spawn when none is registered.
|
||||
Backends:
|
||||
- :class:`ClaudeCodeProvider` — Anthropic-protocol Claude Code container (default;
|
||||
also serves Ollama Cloud / self-hosted via ``ANTHROPIC_BASE_URL`` injection).
|
||||
- :class:`GrokProvider` — xAI ``grok-build-0.1`` over the OpenAI protocol.
|
||||
- :class:`GrokCliProvider` — xAI Grok Build via the official ``grok`` CLI on the
|
||||
SuperGrok subscription (mounted ``~/.grok`` auth, parity with the Claude path).
|
||||
"""
|
||||
|
||||
from roboco.llm.providers.base import AgentProvider, ProviderError, SpawnResult
|
||||
from roboco.llm.providers.claude_code import ClaudeCodeProvider
|
||||
from roboco.llm.providers.grok import GrokProvider
|
||||
from roboco.llm.providers.grok import GrokCliProvider
|
||||
from roboco.llm.providers.registry import ProviderNotRegisteredError, ProviderRegistry
|
||||
|
||||
__all__ = [
|
||||
"AgentProvider",
|
||||
"ClaudeCodeProvider",
|
||||
"GrokProvider",
|
||||
"GrokCliProvider",
|
||||
"ProviderError",
|
||||
"ProviderNotRegisteredError",
|
||||
"ProviderRegistry",
|
||||
|
||||
+58
-186
@@ -1,37 +1,26 @@
|
||||
"""Grok provider — xAI ``grok-build-0.1`` as a native OpenAI-protocol agent.
|
||||
"""Grok CLI provider — xAI Grok Build via the official ``grok`` CLI.
|
||||
|
||||
xAI's API is OpenAI-compatible *only* (``https://api.x.ai/v1``) — there is no
|
||||
native Anthropic-Messages endpoint — so a Grok agent cannot run through the
|
||||
Claude Code path (which speaks the Anthropic Messages API via
|
||||
``ANTHROPIC_BASE_URL`` injection). Instead it runs an OpenAI-protocol agent CLI
|
||||
(the OpenCode pattern), pointed at ``api.x.ai/v1`` with the operator's xAI key.
|
||||
xAI ships an official terminal coding agent (the ``grok`` CLI, "Grok Build")
|
||||
authenticated by the SuperGrok subscription. RoboCo runs Grok agents on it the
|
||||
same way it runs Claude agents on ``claude``: the orchestrator's shared container
|
||||
assembly mounts the RoboCo MCP gateway (``mcp-config.json``), the agent HMAC
|
||||
identity, and the git context; this provider adds the subscription auth mount
|
||||
(``~/.grok``) and the runtime env the grok-cli entrypoint reads, then launches
|
||||
the ``roboco-agent-grok`` image — whose entrypoint renders ``~/.grok/config.toml``
|
||||
+ per-role flags (see :mod:`roboco.llm.providers.grok_cli_config`) and runs
|
||||
``grok -p`` headless.
|
||||
|
||||
Design — this provider deliberately **reuses the orchestrator's proven container
|
||||
assembly** (``_build_mount_args`` / ``_append_agent_auth_env`` /
|
||||
``_append_git_context_env``). That means a Grok agent gets the *same* RoboCo MCP
|
||||
gateway wiring as every other agent **by construction**:
|
||||
|
||||
* ``/app/mcp-config.json`` (the ``roboco-flow`` / ``roboco-do`` gateway) and
|
||||
``/app/system-prompt.md`` are mounted by ``_build_mount_args``;
|
||||
* the spawn manifest + ``ROBOCO_GATEWAY_ENABLED`` are set there too;
|
||||
* the agent HMAC identity is injected by ``_append_agent_auth_env``.
|
||||
|
||||
An OpenAI-protocol provider that built its own bespoke spawn would have to
|
||||
re-wire the MCP config itself, and skipping it leaves agents with zero gateway
|
||||
verbs. Reusing the shared assembly makes the gateway wiring non-optional here.
|
||||
|
||||
Only two things differ from the Claude Code spawn:
|
||||
1. **LLM env** — ``OPENAI_BASE_URL`` / ``OPENAI_API_KEY`` (xAI) instead of the
|
||||
``ANTHROPIC_*`` injection. The provider routing fields are blanked before
|
||||
the shared mount step so the xAI endpoint is never mislabelled as Anthropic.
|
||||
2. **Runtime** — the ``roboco-agent-grok`` image, whose entrypoint launches the
|
||||
OpenAI-protocol CLI from the env below (model, MCP config, system prompt,
|
||||
prompt). The image + the exact CLI invocation are the one remaining piece to
|
||||
finalise together with xAI ("review + help implement + test").
|
||||
Two things differ from the Claude Code spawn:
|
||||
1. **Auth** — the host's ``~/.grok`` (subscription credential from ``grok
|
||||
login``) is mounted instead of relying on a provider key; the xAI API key is
|
||||
never used. The provider routing fields are blanked before the shared mount
|
||||
step so the shared builder never injects them as ``ANTHROPIC_*`` (the wrong
|
||||
runtime) — grok authenticates from the mounted ``~/.grok``.
|
||||
2. **Runtime** — the ``roboco-agent-grok`` image (grok CLI) instead of
|
||||
``claude``.
|
||||
|
||||
The initial prompt is passed via an **env var, not a positional CLI arg**, which
|
||||
structurally avoids a flag-injection vector: a prompt starting with ``--`` passed
|
||||
as a positional argument could otherwise be parsed as CLI options.
|
||||
structurally avoids a flag-injection vector.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -39,109 +28,34 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
from roboco.agents_config import get_agent_role
|
||||
from roboco.llm.providers._docker import container_running, stop_container
|
||||
from roboco.llm.providers.base import AgentProvider, ProviderError, SpawnResult
|
||||
from roboco.services.gateway.role_config import get_role_config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from roboco.models.runtime import OrchestratorAgentConfig as AgentConfig
|
||||
|
||||
# The Grok agent image (own image, like every other agent role). Built as the
|
||||
# infra follow-up — bundles the OpenAI-protocol agent CLI + an entrypoint that
|
||||
# reads the env contract below. Overridable for tests / staged rollout.
|
||||
# The Grok agent image (own image, like every other agent role). Overridable for
|
||||
# tests / staged rollout.
|
||||
_DEFAULT_GROK_IMAGE = os.environ.get(
|
||||
"ROBOCO_GROK_AGENT_IMAGE", "roboco-agent-grok:latest"
|
||||
)
|
||||
|
||||
# Default xAI endpoint when the seeded provider row carries no base_url.
|
||||
_DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1"
|
||||
# The grok CLI model id (the CLI uses ``grok-build``, verified live).
|
||||
_GROK_CLI_MODEL = os.environ.get("ROBOCO_GROK_CLI_MODEL", "grok-build")
|
||||
|
||||
# In-container paths mounted by the orchestrator's `_build_mount_args`.
|
||||
_MCP_CONFIG_IN_CONTAINER = "/app/mcp-config.json"
|
||||
_SYSTEM_PROMPT_IN_CONTAINER = "/app/system-prompt.md"
|
||||
# opencode's data dir inside the agent (HOME=/home/agent); opencode.db lands
|
||||
# here. Mounted to a per-agent host dir so the orchestrator can read usage back
|
||||
# (mirror of roboco.llm.providers.opencode_usage.DEFAULT_DB_PATH's parent).
|
||||
_OPENCODE_DATA_DIR_IN_CONTAINER = "/home/agent/.local/share/opencode"
|
||||
|
||||
# Reasoning effort by role. grok-build-0.1 reasons heavily by default, and
|
||||
# reasoning bills at the output rate, so it dominates cost. Code-quality roles
|
||||
# (developer, qa, pr_reviewer) keep full reasoning; coordination / docs / board
|
||||
# roles request "minimal". opencode receives this via its `--variant` flag (and
|
||||
# the serve message `variant` field). NOTE: whether opencode actually applies a
|
||||
# named reasoning variant to grok-build-0.1 without a provider-defined `variants`
|
||||
# block is UNVERIFIED — passing the flag does not error, but the reasoning-cost
|
||||
# reduction must be measured on the NAS; treat the saving as best-effort, not
|
||||
# guaranteed. Operators can force one effort for ALL grok agents with the
|
||||
# ROBOCO_GROK_REASONING_EFFORT env on the orchestrator (value "minimal" | "high"
|
||||
# | "max", or "default"/"full" to use full reasoning).
|
||||
_MINIMAL_REASONING_ROLES = frozenset(
|
||||
{
|
||||
"cell_pm",
|
||||
"main_pm",
|
||||
"documenter",
|
||||
"product_owner",
|
||||
"head_marketing",
|
||||
"auditor",
|
||||
"prompter",
|
||||
"secretary",
|
||||
}
|
||||
# Host directory holding the SuperGrok auth (from ``grok login``). Mounted into
|
||||
# the agent's ``~/.grok`` like the Claude path mounts ``~/.claude``. Override for
|
||||
# docker-in-docker / NAS deploys (the orchestrator's home is not the host's).
|
||||
GROK_AUTH_HOST_PATH = os.environ.get(
|
||||
"ROBOCO_HOST_GROK_DIR", str(Path.home() / ".grok")
|
||||
)
|
||||
_FULL_REASONING_OVERRIDES = frozenset({"default", "full", "none", ""})
|
||||
|
||||
# Per-role opencode permission policy (Claude-parity with
|
||||
# orchestrator._get_role_permissions). Claude denies Write/Edit for the read-only
|
||||
# roles and Bash(git commit/push) for PMs; opencode's permission is coarser
|
||||
# (allow/deny per tool class), so:
|
||||
# * edit — allow only roles that write code (role_config.allows_write:
|
||||
# developer / documenter). Everyone else edit=deny.
|
||||
# * bash — allow only roles that legitimately run a shell; the read-only
|
||||
# reviewers (qa / pr_reviewer / auditor) and the board never do. secret-scrub
|
||||
# still guards bash (git-mutate / cred files) for the roles that keep it.
|
||||
# * external_directory — only the pr_reviewer reads scratch outside its cwd
|
||||
# (a diff it writes to /tmp). Delivery roles work inside their workspace, so
|
||||
# external_directory=deny (the headless-ask auto-deny that blocked the
|
||||
# pr-reviewer is moot once that one role is explicitly allowed).
|
||||
_BASH_ROLES = frozenset({"developer", "documenter", "cell_pm", "main_pm"})
|
||||
_EXTERNAL_DIR_ROLES = frozenset({"pr_reviewer"})
|
||||
|
||||
|
||||
def _edit_permission_for(agent_id: str) -> str:
|
||||
"""opencode ``edit`` permission for an agent's role (allow iff it writes code)."""
|
||||
role = get_agent_role(agent_id) or ""
|
||||
try:
|
||||
return "allow" if get_role_config(role).allows_write else "deny"
|
||||
except KeyError:
|
||||
return "deny" # unknown role → safest
|
||||
|
||||
|
||||
def _bash_permission_for(agent_id: str) -> str:
|
||||
"""opencode ``bash`` permission for an agent's role."""
|
||||
return "allow" if (get_agent_role(agent_id) or "") in _BASH_ROLES else "deny"
|
||||
|
||||
|
||||
def _external_dir_permission_for(agent_id: str) -> str:
|
||||
"""opencode ``external_directory`` permission for an agent's role."""
|
||||
role = get_agent_role(agent_id) or ""
|
||||
return "allow" if role in _EXTERNAL_DIR_ROLES else "deny"
|
||||
|
||||
|
||||
def _reasoning_effort_for(agent_id: str) -> str | None:
|
||||
"""Resolve the opencode --variant reasoning effort for an agent.
|
||||
|
||||
Returns ``None`` to use opencode's default (full) reasoning. A global
|
||||
override env wins over the per-role default.
|
||||
"""
|
||||
override = os.environ.get("ROBOCO_GROK_REASONING_EFFORT", "").strip()
|
||||
if override:
|
||||
return None if override.lower() in _FULL_REASONING_OVERRIDES else override
|
||||
role = get_agent_role(agent_id) or ""
|
||||
return "minimal" if role in _MINIMAL_REASONING_ROLES else None
|
||||
# In-container paths.
|
||||
_MCP_CONFIG_IN_CONTAINER = "/app/mcp-config.json"
|
||||
_GROK_AUTH_IN_CONTAINER = "/home/agent/.grok/auth.json"
|
||||
|
||||
|
||||
def _container_name(agent_id: str) -> str:
|
||||
@@ -149,16 +63,14 @@ def _container_name(agent_id: str) -> str:
|
||||
|
||||
|
||||
class _GrokHost(Protocol):
|
||||
"""The orchestrator surface GrokProvider reuses for container assembly.
|
||||
"""The orchestrator surface GrokCliProvider reuses for container assembly.
|
||||
|
||||
Typed as a Protocol so this module never imports ``AgentOrchestrator``
|
||||
(no import cycle) and is trivially mockable in tests.
|
||||
Typed as a Protocol so this module never imports ``AgentOrchestrator`` (no
|
||||
import cycle) and is trivially mockable in tests.
|
||||
"""
|
||||
|
||||
async def _remove_container(self, container_name: str) -> None: ...
|
||||
|
||||
def _ensure_opencode_data_dir(self, agent_id: str) -> None: ...
|
||||
|
||||
def _resolve_host_paths(
|
||||
self, config: AgentConfig, agent_settings_path: Path | None
|
||||
) -> dict[str, str | None]: ...
|
||||
@@ -175,8 +87,8 @@ class _GrokHost(Protocol):
|
||||
def _append_git_context_env(self, cmd: list[str], config: AgentConfig) -> None: ...
|
||||
|
||||
|
||||
class GrokProvider(AgentProvider):
|
||||
"""Spawn a Grok (xAI, OpenAI-protocol) agent as a gateway-wired container."""
|
||||
class GrokCliProvider(AgentProvider):
|
||||
"""Spawn a Grok (xAI, official CLI) agent as a gateway-wired container."""
|
||||
|
||||
def __init__(self, host: _GrokHost, image: str | None = None) -> None:
|
||||
self._host = host
|
||||
@@ -188,12 +100,6 @@ class GrokProvider(AgentProvider):
|
||||
initial_prompt: str | None = None,
|
||||
agent_settings_path: Path | None = None,
|
||||
) -> SpawnResult:
|
||||
if not config.provider_auth_token:
|
||||
raise ProviderError(
|
||||
"GROK spawn requires an xAI API key — set the Grok provider key "
|
||||
"in Settings (PUT /api/providers/grok/key).",
|
||||
agent_id=config.agent_id,
|
||||
)
|
||||
if not config.mcp_config_path:
|
||||
raise ProviderError(
|
||||
"GROK spawn requires an MCP config (gateway access).",
|
||||
@@ -202,14 +108,12 @@ class GrokProvider(AgentProvider):
|
||||
|
||||
container_name = _container_name(config.agent_id)
|
||||
await self._host._remove_container(container_name)
|
||||
# Pre-create the opencode store dir (world-writable) before the bind mount
|
||||
# so the non-root agent user can write opencode.db / repos (else EACCES).
|
||||
self._host._ensure_opencode_data_dir(config.agent_id)
|
||||
|
||||
# Reuse the orchestrator's mount/auth/git assembly so the agent gets the
|
||||
# full MCP gateway + identity wiring. Blank the provider routing fields
|
||||
# first: otherwise the shared builder would inject the xAI endpoint as
|
||||
# ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN (wrong protocol).
|
||||
# first: otherwise the shared builder would inject the provider endpoint
|
||||
# as ANTHROPIC_BASE_URL/AUTH_TOKEN — grok authenticates from the mounted
|
||||
# ~/.grok, not a provider key.
|
||||
mount_config = dataclasses.replace(
|
||||
config, provider_base_url=None, provider_auth_token=None
|
||||
)
|
||||
@@ -217,7 +121,7 @@ class GrokProvider(AgentProvider):
|
||||
cmd = self._host._build_mount_args(container_name, mount_config, hosts)
|
||||
self._host._append_agent_auth_env(cmd, config)
|
||||
self._host._append_git_context_env(cmd, config)
|
||||
self._append_opencode_data_mount(cmd, hosts)
|
||||
self._append_grok_auth_mount(cmd)
|
||||
self._append_grok_env(cmd, config, initial_prompt)
|
||||
cmd.append(self._image)
|
||||
|
||||
@@ -234,75 +138,43 @@ class GrokProvider(AgentProvider):
|
||||
)
|
||||
return SpawnResult(
|
||||
instance_id=container_name,
|
||||
extra={"container_id": stdout.decode().strip(), "model": config.model},
|
||||
extra={"container_id": stdout.decode().strip(), "model": _GROK_CLI_MODEL},
|
||||
)
|
||||
|
||||
def _append_opencode_data_mount(
|
||||
self, cmd: list[str], hosts: dict[str, str | None]
|
||||
) -> None:
|
||||
"""Mount the per-agent opencode data dir so the orchestrator can read it.
|
||||
@staticmethod
|
||||
def _append_grok_auth_mount(cmd: list[str]) -> None:
|
||||
"""Mount the host's SuperGrok ``auth.json`` (read-only) into ~/.grok.
|
||||
|
||||
opencode persists token usage to ``opencode.db`` under its data dir
|
||||
(``$HOME/.local/share/opencode``). Binding a per-agent host dir there
|
||||
lets the finalizer read the store back over the shared data volume —
|
||||
the opencode analogue of the mounted Claude transcript. Without this a
|
||||
Grok agent finalizes at 0 tokens / $0.
|
||||
Read-only so concurrent containers can't corrupt the shared subscription
|
||||
credential; grok writes its per-run state (the rendered ``config.toml``,
|
||||
``sessions/``) into the image's own ``~/.grok``. One-shot delivery runs
|
||||
are short, so the token needs no mid-run refresh.
|
||||
"""
|
||||
opencode_host = hosts.get("opencode")
|
||||
if opencode_host:
|
||||
cmd.extend(["-v", f"{opencode_host}:{_OPENCODE_DATA_DIR_IN_CONTAINER}"])
|
||||
auth_json = Path(GROK_AUTH_HOST_PATH) / "auth.json"
|
||||
if auth_json.exists():
|
||||
cmd.extend(["-v", f"{auth_json}:{_GROK_AUTH_IN_CONTAINER}:ro"])
|
||||
|
||||
def _append_grok_env(
|
||||
self, cmd: list[str], config: AgentConfig, initial_prompt: str | None
|
||||
) -> None:
|
||||
"""Append the OpenAI-protocol (xAI) env contract the grok image consumes.
|
||||
"""Append the runtime env the grok-cli entrypoint + renderer read.
|
||||
|
||||
The prompt travels as an env var, never an argv positional, so a prompt
|
||||
beginning with ``--`` cannot be parsed as a CLI flag.
|
||||
``ROBOCO_AGENT_ID`` lets the renderer compute the per-role flags;
|
||||
``ROBOCO_MCP_CONFIG`` points it at the mounted gateway config; the prompt
|
||||
travels as an env var (never an argv positional).
|
||||
"""
|
||||
base_url = config.provider_base_url or _DEFAULT_XAI_BASE_URL
|
||||
cmd.extend(
|
||||
[
|
||||
# opencode's BUILT-IN xai provider authenticates from XAI_API_KEY
|
||||
# and reads XAI_BASE_URL for the endpoint — opencode_config emits
|
||||
# no provider block, so these envs are the only LLM wiring needed.
|
||||
"-e",
|
||||
f"XAI_API_KEY={config.provider_auth_token}",
|
||||
f"ROBOCO_AGENT_ID={config.agent_id}",
|
||||
"-e",
|
||||
f"XAI_BASE_URL={base_url}",
|
||||
# Operational inputs for the grok image entrypoint.
|
||||
"-e",
|
||||
f"ROBOCO_AGENT_MODEL={config.model}",
|
||||
f"ROBOCO_AGENT_MODEL={_GROK_CLI_MODEL}",
|
||||
"-e",
|
||||
f"ROBOCO_MCP_CONFIG={_MCP_CONFIG_IN_CONTAINER}",
|
||||
"-e",
|
||||
f"ROBOCO_SYSTEM_PROMPT={_SYSTEM_PROMPT_IN_CONTAINER}",
|
||||
"-e",
|
||||
f"ROBOCO_INITIAL_PROMPT={initial_prompt or ''}",
|
||||
]
|
||||
)
|
||||
if config.claude_session_id:
|
||||
# Reused as the generic agent session id so the transcript stays
|
||||
# locatable at finalize, exactly as on the Claude Code path.
|
||||
cmd.extend(["-e", f"ROBOCO_AGENT_SESSION_ID={config.claude_session_id}"])
|
||||
# Per-role opencode permissions (Claude-parity): read-only roles get
|
||||
# edit=deny, only delivery roles get bash, only the pr-reviewer gets
|
||||
# external-directory reads. opencode_config.main() reads these.
|
||||
cmd.extend(
|
||||
[
|
||||
"-e",
|
||||
f"ROBOCO_GROK_EDIT_PERMISSION={_edit_permission_for(config.agent_id)}",
|
||||
"-e",
|
||||
f"ROBOCO_GROK_BASH_PERMISSION={_bash_permission_for(config.agent_id)}",
|
||||
"-e",
|
||||
"ROBOCO_GROK_EXTERNAL_DIR_PERMISSION="
|
||||
f"{_external_dir_permission_for(config.agent_id)}",
|
||||
]
|
||||
)
|
||||
# Reasoning effort (opencode --variant) by role; omitted = full reasoning.
|
||||
variant = _reasoning_effort_for(config.agent_id)
|
||||
if variant:
|
||||
cmd.extend(["-e", f"ROBOCO_GROK_VARIANT={variant}"])
|
||||
|
||||
async def stop(self, instance_id: str, graceful: bool = True) -> None:
|
||||
await stop_container(instance_id, graceful)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Usage::
|
||||
|
||||
registry = ProviderRegistry()
|
||||
registry.register(ModelProvider.GROK, GrokProvider(...))
|
||||
registry.register(ModelProvider.GROK, GrokCliProvider(...))
|
||||
provider = registry.get(ModelProvider.GROK)
|
||||
result = await provider.spawn(config, initial_prompt)
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ MODEL_CATALOG: tuple[CatalogEntry, ...] = (
|
||||
CatalogEntry("kimi-k2.6:cloud", ModelProvider.OLLAMA_CLOUD, "Kimi K2.6"),
|
||||
CatalogEntry("minimax-m3:cloud", ModelProvider.OLLAMA_CLOUD, "Minimax M3"),
|
||||
# --- Grok (xAI, OpenAI protocol) ---
|
||||
# Routes to the GROK provider → GrokProvider spawn (api.x.ai/v1). The xAI
|
||||
# Routes to the GROK provider → GrokCliProvider spawn (api.x.ai/v1). The xAI
|
||||
# key is set via PUT /api/providers/grok/key.
|
||||
CatalogEntry("grok-build-0.1", ModelProvider.GROK, "Grok Build 0.1"),
|
||||
)
|
||||
|
||||
@@ -2076,7 +2076,7 @@ class AgentOrchestrator:
|
||||
container are registered. Today that is GROK (xAI, OpenAI protocol).
|
||||
"""
|
||||
if self._provider_registry is None:
|
||||
from roboco.llm.providers import GrokProvider, ProviderRegistry
|
||||
from roboco.llm.providers import GrokCliProvider, ProviderRegistry
|
||||
from roboco.models.base import ModelProvider
|
||||
|
||||
registry = ProviderRegistry()
|
||||
@@ -2085,7 +2085,7 @@ class AgentOrchestrator:
|
||||
# get_agent_image for the Claude path).
|
||||
registry.register(
|
||||
ModelProvider.GROK,
|
||||
GrokProvider(self, image=_qualify_agent_image("roboco-agent-grok")),
|
||||
GrokCliProvider(self, image=_qualify_agent_image("roboco-agent-grok")),
|
||||
)
|
||||
self._provider_registry = registry
|
||||
return self._provider_registry
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""Tests for the LLM agent provider seam.
|
||||
|
||||
Covers the ProviderRegistry, the ClaudeCodeProvider adapter, and the
|
||||
GrokProvider (xAI / OpenAI protocol) — especially the safety properties an
|
||||
OpenAI-protocol agent provider must hold:
|
||||
GrokCliProvider (xAI Grok Build via the official ``grok`` CLI) — especially the
|
||||
safety properties the Grok provider must hold:
|
||||
|
||||
* the agent gets the MCP gateway wiring (reuses the orchestrator mount path);
|
||||
* the xAI endpoint is injected as XAI_* and never mislabelled ANTHROPIC_*;
|
||||
* the subscription auth (~/.grok) is mounted, and the provider routing fields
|
||||
are blanked so the grok endpoint is never mislabelled ANTHROPIC_*;
|
||||
* the prompt travels via env, so a leading ``--`` cannot become a CLI flag.
|
||||
"""
|
||||
|
||||
@@ -17,22 +18,28 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
from roboco.llm.providers import (
|
||||
ClaudeCodeProvider,
|
||||
GrokProvider,
|
||||
GrokCliProvider,
|
||||
ProviderError,
|
||||
ProviderNotRegisteredError,
|
||||
ProviderRegistry,
|
||||
SpawnResult,
|
||||
)
|
||||
from roboco.llm.providers.grok import (
|
||||
_bash_permission_for,
|
||||
_edit_permission_for,
|
||||
_external_dir_permission_for,
|
||||
_reasoning_effort_for,
|
||||
)
|
||||
from roboco.models.base import ModelProvider
|
||||
from roboco.models.runtime import OrchestratorAgentConfig
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_grok_auth(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> Path:
|
||||
"""Point GROK_AUTH_HOST_PATH at a fresh tmp dir so tests never mount the real
|
||||
~/.grok. Tests that exercise the auth mount create ``auth.json`` themselves."""
|
||||
monkeypatch.setattr(
|
||||
"roboco.llm.providers.grok.GROK_AUTH_HOST_PATH", str(tmp_path)
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _config(
|
||||
*,
|
||||
agent_id: str = "be-dev-1",
|
||||
@@ -60,7 +67,6 @@ class _FakeHost:
|
||||
self.removed: list[str] = []
|
||||
self.spawn_args: tuple[object, ...] | None = None
|
||||
self.mount_config: OrchestratorAgentConfig | None = None
|
||||
self.opencode_dirs_ensured: list[str] = []
|
||||
|
||||
async def _spawn_container(
|
||||
self,
|
||||
@@ -74,9 +80,6 @@ class _FakeHost:
|
||||
async def _remove_container(self, container_name: str) -> None:
|
||||
self.removed.append(container_name)
|
||||
|
||||
def _ensure_opencode_data_dir(self, agent_id: str) -> None:
|
||||
self.opencode_dirs_ensured.append(agent_id)
|
||||
|
||||
def _resolve_host_paths(
|
||||
self, config: OrchestratorAgentConfig, agent_settings_path: Path | None
|
||||
) -> dict[str, str | None]:
|
||||
@@ -85,7 +88,6 @@ class _FakeHost:
|
||||
if config.mcp_config_path
|
||||
else None,
|
||||
"settings": str(agent_settings_path) if agent_settings_path else None,
|
||||
"opencode": f"/host/opencode/{config.agent_id}",
|
||||
}
|
||||
|
||||
def _build_mount_args(
|
||||
@@ -134,7 +136,7 @@ def _proc(
|
||||
|
||||
def test_registry_register_and_get() -> None:
|
||||
registry = ProviderRegistry()
|
||||
provider = GrokProvider(_FakeHost())
|
||||
provider = GrokCliProvider(_FakeHost())
|
||||
registry.register(ModelProvider.GROK, provider)
|
||||
assert registry.get(ModelProvider.GROK) is provider
|
||||
assert registry.is_registered(ModelProvider.GROK)
|
||||
@@ -154,42 +156,43 @@ def test_registry_get_or_none_returns_none_when_absent() -> None:
|
||||
|
||||
def test_registry_unregister() -> None:
|
||||
registry = ProviderRegistry()
|
||||
registry.register(ModelProvider.GROK, GrokProvider(_FakeHost()))
|
||||
registry.register(ModelProvider.GROK, GrokCliProvider(_FakeHost()))
|
||||
registry.unregister(ModelProvider.GROK)
|
||||
assert not registry.is_registered(ModelProvider.GROK)
|
||||
registry.unregister(ModelProvider.GROK) # idempotent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GrokProvider
|
||||
# GrokCliProvider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_grok_spawn_requires_api_key() -> None:
|
||||
provider = GrokProvider(_FakeHost())
|
||||
with pytest.raises(ProviderError, match="xAI API key"):
|
||||
await provider.spawn(_config(provider_auth_token=None))
|
||||
|
||||
|
||||
async def test_grok_spawn_requires_mcp_config() -> None:
|
||||
provider = GrokProvider(_FakeHost())
|
||||
provider = GrokCliProvider(_FakeHost())
|
||||
with pytest.raises(ProviderError, match="MCP config"):
|
||||
await provider.spawn(_config(mcp_config_path=None))
|
||||
|
||||
|
||||
async def test_grok_spawn_injects_xai_env_and_no_anthropic_leak() -> None:
|
||||
async def test_grok_spawn_does_not_require_api_key() -> None:
|
||||
# Subscription auth (mounted ~/.grok) — a missing provider key is fine.
|
||||
host = _FakeHost()
|
||||
provider = GrokProvider(host, image="roboco-agent-grok:test")
|
||||
provider = GrokCliProvider(host)
|
||||
with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())):
|
||||
result = await provider.spawn(_config(provider_auth_token=None))
|
||||
assert result.instance_id == "roboco-agent-be-dev-1"
|
||||
|
||||
|
||||
async def test_grok_spawn_no_xai_key_and_no_anthropic_leak() -> None:
|
||||
host = _FakeHost()
|
||||
provider = GrokCliProvider(host, image="roboco-agent-grok:test")
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
|
||||
) as exec_mock:
|
||||
await provider.spawn(_config(), initial_prompt="do the work")
|
||||
cmd = list(exec_mock.call_args.args)
|
||||
# opencode's built-in xai provider reads XAI_API_KEY / XAI_BASE_URL (no
|
||||
# provider block in the rendered config — that breaks plugin-tool reg).
|
||||
assert "XAI_API_KEY=xai-secret-key" in cmd
|
||||
assert "XAI_BASE_URL=https://api.x.ai/v1" in cmd
|
||||
# The xAI endpoint must NOT be injected as an Anthropic var.
|
||||
# The CLI authenticates from the mounted ~/.grok — the xAI key is never used.
|
||||
assert not any(c.startswith("XAI_API_KEY=") for c in cmd)
|
||||
# The provider endpoint must NOT be injected as an Anthropic var.
|
||||
assert not any(c.startswith("ANTHROPIC_BASE_URL=") for c in cmd)
|
||||
assert not any(c.startswith("ANTHROPIC_AUTH_TOKEN=") for c in cmd)
|
||||
# Provider fields were blanked before the shared mount step.
|
||||
@@ -198,23 +201,18 @@ async def test_grok_spawn_injects_xai_env_and_no_anthropic_leak() -> None:
|
||||
assert host.mount_config.provider_auth_token is None
|
||||
|
||||
|
||||
async def test_grok_spawn_wires_gateway_and_image_last() -> None:
|
||||
async def test_grok_spawn_wires_gateway_env_and_image_last() -> None:
|
||||
host = _FakeHost()
|
||||
provider = GrokProvider(host, image="roboco-agent-grok:test")
|
||||
provider = GrokCliProvider(host, image="roboco-agent-grok:test")
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
|
||||
) as exec_mock:
|
||||
result = await provider.spawn(_config())
|
||||
cmd = list(exec_mock.call_args.args)
|
||||
# Gateway + operational env the grok image entrypoint consumes.
|
||||
# Gateway + operational env the grok-cli entrypoint + renderer consume.
|
||||
assert "ROBOCO_MCP_CONFIG=/app/mcp-config.json" in cmd
|
||||
assert "ROBOCO_SYSTEM_PROMPT=/app/system-prompt.md" in cmd
|
||||
# Tool restriction lives in the rendered opencode.json (opencode `tools`),
|
||||
# not a spawn env var — no ROBOCO_AGENT_TOOLS is injected.
|
||||
assert not any(c.startswith("ROBOCO_AGENT_TOOLS=") for c in cmd)
|
||||
# The opencode store is mounted so the orchestrator can read usage/cost
|
||||
# back at finalize.
|
||||
assert "/host/opencode/be-dev-1:/home/agent/.local/share/opencode" in cmd
|
||||
assert "ROBOCO_AGENT_ID=be-dev-1" in cmd # renderer computes per-role flags
|
||||
assert "ROBOCO_AGENT_MODEL=grok-build" in cmd
|
||||
# Identity wiring from the shared host helpers is present.
|
||||
assert "ROBOCO_AGENT_TOKEN=hmac-be-dev-1" in cmd
|
||||
# The image is the final docker-run argument.
|
||||
@@ -222,13 +220,38 @@ async def test_grok_spawn_wires_gateway_and_image_last() -> None:
|
||||
assert host.removed == ["roboco-agent-be-dev-1"]
|
||||
assert result == SpawnResult(
|
||||
instance_id="roboco-agent-be-dev-1",
|
||||
extra={"container_id": "cid", "model": "grok-build-0.1"},
|
||||
extra={"container_id": "cid", "model": "grok-build"},
|
||||
)
|
||||
|
||||
|
||||
async def test_grok_spawn_mounts_auth_when_present(_isolate_grok_auth: Path) -> None:
|
||||
(_isolate_grok_auth / "auth.json").write_text("{}", encoding="utf-8")
|
||||
host = _FakeHost()
|
||||
provider = GrokCliProvider(host)
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
|
||||
) as exec_mock:
|
||||
await provider.spawn(_config())
|
||||
cmd = list(exec_mock.call_args.args)
|
||||
expected = f"{_isolate_grok_auth / 'auth.json'}:/home/agent/.grok/auth.json:ro"
|
||||
assert expected in cmd
|
||||
|
||||
|
||||
async def test_grok_spawn_omits_auth_mount_when_absent() -> None:
|
||||
# No auth.json in the (tmp) GROK_AUTH_HOST_PATH → no mount, no crash.
|
||||
host = _FakeHost()
|
||||
provider = GrokCliProvider(host)
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
|
||||
) as exec_mock:
|
||||
await provider.spawn(_config())
|
||||
cmd = list(exec_mock.call_args.args)
|
||||
assert not any("/home/agent/.grok/auth.json" in c for c in cmd)
|
||||
|
||||
|
||||
async def test_grok_spawn_prompt_is_injection_safe() -> None:
|
||||
host = _FakeHost()
|
||||
provider = GrokProvider(host)
|
||||
provider = GrokCliProvider(host)
|
||||
nasty = "--model evil --session-id pwned"
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
|
||||
@@ -240,18 +263,8 @@ async def test_grok_spawn_prompt_is_injection_safe() -> None:
|
||||
assert nasty not in cmd
|
||||
|
||||
|
||||
async def test_grok_spawn_defaults_base_url_when_route_blank() -> None:
|
||||
provider = GrokProvider(_FakeHost())
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
|
||||
) as exec_mock:
|
||||
await provider.spawn(_config(provider_base_url=None))
|
||||
cmd = list(exec_mock.call_args.args)
|
||||
assert "XAI_BASE_URL=https://api.x.ai/v1" in cmd
|
||||
|
||||
|
||||
async def test_grok_spawn_raises_on_docker_failure() -> None:
|
||||
provider = GrokProvider(_FakeHost())
|
||||
provider = GrokCliProvider(_FakeHost())
|
||||
with (
|
||||
patch(
|
||||
"asyncio.create_subprocess_exec",
|
||||
@@ -262,105 +275,6 @@ async def test_grok_spawn_raises_on_docker_failure() -> None:
|
||||
await provider.spawn(_config())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reasoning effort by role
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_reasoning_effort_full_for_code_roles() -> None:
|
||||
# developer / qa / pr_reviewer keep full reasoning (no variant).
|
||||
assert _reasoning_effort_for("be-dev-1") is None
|
||||
assert _reasoning_effort_for("be-qa") is None
|
||||
assert _reasoning_effort_for("pr-reviewer-1") is None
|
||||
|
||||
|
||||
def test_reasoning_effort_minimal_for_coordination_roles() -> None:
|
||||
for slug in ("be-pm", "main-pm", "be-doc", "auditor", "product-owner"):
|
||||
assert _reasoning_effort_for(slug) == "minimal", slug
|
||||
|
||||
|
||||
def test_reasoning_effort_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ROBOCO_GROK_REASONING_EFFORT", "max")
|
||||
assert _reasoning_effort_for("be-dev-1") == "max" # override wins over role
|
||||
monkeypatch.setenv("ROBOCO_GROK_REASONING_EFFORT", "default")
|
||||
assert _reasoning_effort_for("be-pm") is None # "default" => full reasoning
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-role opencode permissions (Claude-parity)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_edit_permission_allows_only_writer_roles() -> None:
|
||||
assert _edit_permission_for("be-dev-1") == "allow"
|
||||
assert _edit_permission_for("be-doc") == "allow"
|
||||
for slug in ("be-qa", "pr-reviewer-1", "be-pm", "main-pm", "auditor"):
|
||||
assert _edit_permission_for(slug) == "deny", slug
|
||||
|
||||
|
||||
def test_bash_permission_allows_only_shell_roles() -> None:
|
||||
for slug in ("be-dev-1", "be-doc", "be-pm", "main-pm"):
|
||||
assert _bash_permission_for(slug) == "allow", slug
|
||||
for slug in ("be-qa", "pr-reviewer-1", "auditor", "product-owner"):
|
||||
assert _bash_permission_for(slug) == "deny", slug
|
||||
|
||||
|
||||
def test_external_dir_permission_only_pr_reviewer() -> None:
|
||||
assert _external_dir_permission_for("pr-reviewer-1") == "allow"
|
||||
for slug in ("be-dev-1", "be-qa", "be-pm", "auditor"):
|
||||
assert _external_dir_permission_for(slug) == "deny", slug
|
||||
|
||||
|
||||
async def test_grok_spawn_sets_readonly_permissions_for_reviewer() -> None:
|
||||
# A read-only reviewer (qa) gets edit=deny + bash=deny + external_dir=deny.
|
||||
host = _FakeHost()
|
||||
provider = GrokProvider(host)
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
|
||||
) as exec_mock:
|
||||
await provider.spawn(_config(agent_id="be-qa"))
|
||||
cmd = list(exec_mock.call_args.args)
|
||||
assert "ROBOCO_GROK_EDIT_PERMISSION=deny" in cmd
|
||||
assert "ROBOCO_GROK_BASH_PERMISSION=deny" in cmd
|
||||
assert "ROBOCO_GROK_EXTERNAL_DIR_PERMISSION=deny" in cmd
|
||||
|
||||
|
||||
async def test_grok_spawn_pr_reviewer_is_read_only_but_reads_scratch() -> None:
|
||||
# The pr-reviewer never writes code (edit=deny) but reads its /tmp diff
|
||||
# (external_directory=allow) — the one role that needs it.
|
||||
host = _FakeHost()
|
||||
provider = GrokProvider(host)
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
|
||||
) as exec_mock:
|
||||
await provider.spawn(_config(agent_id="pr-reviewer-1"))
|
||||
cmd = list(exec_mock.call_args.args)
|
||||
assert "ROBOCO_GROK_EDIT_PERMISSION=deny" in cmd
|
||||
assert "ROBOCO_GROK_EXTERNAL_DIR_PERMISSION=allow" in cmd
|
||||
|
||||
|
||||
async def test_grok_spawn_sets_variant_for_minimal_role() -> None:
|
||||
host = _FakeHost()
|
||||
provider = GrokProvider(host)
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
|
||||
) as exec_mock:
|
||||
await provider.spawn(_config(agent_id="be-pm")) # cell_pm -> minimal
|
||||
cmd = list(exec_mock.call_args.args)
|
||||
assert "ROBOCO_GROK_VARIANT=minimal" in cmd
|
||||
|
||||
|
||||
async def test_grok_spawn_no_variant_for_dev_role() -> None:
|
||||
host = _FakeHost()
|
||||
provider = GrokProvider(host)
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
|
||||
) as exec_mock:
|
||||
await provider.spawn(_config(agent_id="be-dev-1")) # developer -> full
|
||||
cmd = list(exec_mock.call_args.args)
|
||||
assert not any(c.startswith("ROBOCO_GROK_VARIANT=") for c in cmd)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ClaudeCodeProvider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""The orchestrator routes only dedicated-backend providers through the registry.
|
||||
|
||||
GROK gets the GrokProvider; Anthropic / Ollama Cloud / self-hosted (and any
|
||||
GROK gets the GrokCliProvider; Anthropic / Ollama Cloud / self-hosted (and any
|
||||
unknown value) return None so ``_spawn_container`` runs its built-in Claude Code
|
||||
path unchanged. This keeps the GROK addition purely additive.
|
||||
"""
|
||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from roboco.llm.providers import GrokProvider
|
||||
from roboco.llm.providers import GrokCliProvider
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ def _make_orch() -> AgentOrchestrator:
|
||||
|
||||
|
||||
def test_provider_for_grok_returns_grok_provider() -> None:
|
||||
assert isinstance(_make_orch()._provider_for("grok"), GrokProvider)
|
||||
assert isinstance(_make_orch()._provider_for("grok"), GrokCliProvider)
|
||||
|
||||
|
||||
def test_provider_for_anthropic_returns_none() -> None:
|
||||
|
||||
Reference in New Issue
Block a user