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:
@@ -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