From aa4dc08d58a95dddae7aa6c8aa622d2054fb4f79 Mon Sep 17 00:00:00 2001 From: Renn F Date: Fri, 19 Jun 2026 03:01:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(grok-cli):=20GrokCliProvider=20=E2=80=94?= =?UTF-8?q?=20subscription=20auth=20mount,=20mirrors=20ClaudeCodeProvider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- roboco/llm/providers/__init__.py | 7 +- roboco/llm/providers/grok.py | 244 +++++--------------- roboco/llm/providers/registry.py | 2 +- roboco/models/llm_catalog.py | 2 +- roboco/runtime/orchestrator.py | 4 +- tests/unit/llm/test_providers.py | 222 ++++++------------ tests/unit/runtime/test_provider_routing.py | 6 +- 7 files changed, 137 insertions(+), 350 deletions(-) diff --git a/roboco/llm/providers/__init__.py b/roboco/llm/providers/__init__.py index 8ad798d2..d886d6b1 100644 --- a/roboco/llm/providers/__init__.py +++ b/roboco/llm/providers/__init__.py @@ -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", diff --git a/roboco/llm/providers/grok.py b/roboco/llm/providers/grok.py index 2f8e9452..d52504ff 100644 --- a/roboco/llm/providers/grok.py +++ b/roboco/llm/providers/grok.py @@ -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) diff --git a/roboco/llm/providers/registry.py b/roboco/llm/providers/registry.py index d240f573..87220daa 100644 --- a/roboco/llm/providers/registry.py +++ b/roboco/llm/providers/registry.py @@ -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) diff --git a/roboco/models/llm_catalog.py b/roboco/models/llm_catalog.py index fd4ec9ed..a3421aaf 100644 --- a/roboco/models/llm_catalog.py +++ b/roboco/models/llm_catalog.py @@ -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"), ) diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index e8d35f82..853679e0 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -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 diff --git a/tests/unit/llm/test_providers.py b/tests/unit/llm/test_providers.py index 2a3f221c..2c3149c6 100644 --- a/tests/unit/llm/test_providers.py +++ b/tests/unit/llm/test_providers.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/unit/runtime/test_provider_routing.py b/tests/unit/runtime/test_provider_routing.py index a6c4e02a..4a9f2ac9 100644 --- a/tests/unit/runtime/test_provider_routing.py +++ b/tests/unit/runtime/test_provider_routing.py @@ -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: