"""Tests for GeminiCliProvider (Google Gemini via the official ``gemini`` CLI). Mirrors ``tests/unit/llm/test_providers.py``'s Grok coverage — the same safety properties matter here: * the agent gets the MCP gateway wiring (reuses the orchestrator mount path); * the OAuth credential (~/.gemini) is mounted, and the provider routing fields are blanked so the gemini endpoint is never mislabelled ANTHROPIC_*; * the prompt travels via env, so a leading ``--`` cannot become a CLI flag. """ from __future__ import annotations from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from roboco.config import settings from roboco.llm.providers import GeminiCliProvider, ProviderError, SpawnResult from roboco.llm.providers import gemini as gemini_module from roboco.models.runtime import OrchestratorAgentConfig def test_gemini_cli_model_is_a_real_settings_field() -> None: # Parity with codex_cli_model (roboco.config.Settings.codex_cli_model) — # gemini.py reads settings.gemini_cli_model, not a raw os.environ.get. assert settings.gemini_cli_model == gemini_module._GEMINI_CLI_MODEL assert settings.gemini_cli_model == "gemini-2.5-pro" @pytest.fixture(autouse=True) def _isolate_gemini_auth(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """Point GEMINI_AUTH_HOST_PATH at a fresh tmp dir so tests never mount the real ~/.gemini. Tests that exercise the auth mount create oauth_creds.json themselves.""" monkeypatch.setattr( "roboco.llm.providers.gemini.GEMINI_AUTH_HOST_PATH", str(tmp_path) ) return tmp_path def _config( *, agent_id: str = "be-dev-1", provider_type: str = "gemini", provider_base_url: str | None = None, provider_auth_token: str | None = None, mcp_config_path: Path | None = Path("/host/mcp-configs/be-dev-1.json"), ) -> OrchestratorAgentConfig: return OrchestratorAgentConfig( agent_id=agent_id, blueprint_path=Path("/app/system-prompt.md"), model="gemini-2.5-pro", mcp_config_path=mcp_config_path, claude_session_id="sess-1", provider_type=provider_type, provider_base_url=provider_base_url, provider_auth_token=provider_auth_token, ) class _FakeHost: """Implements the orchestrator surface the provider delegates to.""" def __init__(self) -> None: self.removed: list[str] = [] self.remove_stop_reasons: list[str | None] = [] self.mount_config: OrchestratorAgentConfig | None = None self.data_dirs_ensured: list[str] = [] async def _remove_container( self, container_name: str, *, stop_reason: str | None = None ) -> None: self.removed.append(container_name) self.remove_stop_reasons.append(stop_reason) def _ensure_gemini_usage_dir(self, agent_id: str) -> None: self.data_dirs_ensured.append(agent_id) def _resolve_host_paths( self, config: OrchestratorAgentConfig, agent_settings_path: Path | None ) -> dict[str, str | None]: return { "mcp_config": str(config.mcp_config_path) if config.mcp_config_path else None, "settings": str(agent_settings_path) if agent_settings_path else None, "gemini_usage": f"/host/data/gemini-usage/{config.agent_id}", } def _build_mount_args( self, container_name: str, config: OrchestratorAgentConfig, hosts: dict[str, str | None], ) -> list[str]: # Record the config the mount step saw, and MIMIC the real # _append_provider_env so a missed blanking would leak ANTHROPIC_*. self.mount_config = config cmd = ["docker", "run", "-d", "--name", container_name] mcp = hosts.get("mcp_config") if mcp: cmd += ["-v", f"{mcp}:/app/mcp-config.json:ro"] if config.provider_base_url: cmd += ["-e", f"ANTHROPIC_BASE_URL={config.provider_base_url}"] if config.provider_auth_token: cmd += ["-e", f"ANTHROPIC_AUTH_TOKEN={config.provider_auth_token}"] return cmd def _append_agent_auth_env( self, cmd: list[str], config: OrchestratorAgentConfig ) -> None: cmd += ["-e", f"ROBOCO_AGENT_TOKEN=hmac-{config.agent_id}"] def _append_git_context_env( self, cmd: list[str], config: OrchestratorAgentConfig ) -> None: cmd += ["-e", f"ROBOCO_GIT_AGENT={config.agent_id}"] def _proc( returncode: int = 0, stdout: bytes = b"cid\n", stderr: bytes = b"" ) -> MagicMock: proc = MagicMock() proc.returncode = returncode proc.communicate = AsyncMock(return_value=(stdout, stderr)) return proc async def test_gemini_spawn_requires_mcp_config() -> None: provider = GeminiCliProvider(_FakeHost()) with pytest.raises(ProviderError, match="MCP config"): await provider.spawn(_config(mcp_config_path=None)) async def test_gemini_spawn_does_not_require_api_key() -> None: # OAuth login (mounted ~/.gemini) — a missing provider key/url is fine. host = _FakeHost() provider = GeminiCliProvider(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_gemini_spawn_no_anthropic_leak() -> None: host = _FakeHost() provider = GeminiCliProvider(host, image="roboco-agent-gemini:test") with patch( "asyncio.create_subprocess_exec", AsyncMock(return_value=_proc()) ) as exec_mock: await provider.spawn( _config(provider_base_url="https://ignored", provider_auth_token="ignored"), initial_prompt="do the work", ) cmd = list(exec_mock.call_args.args) # 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. assert host.mount_config is not None assert host.mount_config.provider_base_url is None assert host.mount_config.provider_auth_token is None async def test_gemini_spawn_wires_gateway_env_and_image_last() -> None: host = _FakeHost() provider = GeminiCliProvider(host, image="roboco-agent-gemini: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) assert "ROBOCO_MCP_CONFIG=/app/mcp-config.json" in cmd assert "ROBOCO_AGENT_ID=be-dev-1" in cmd assert "ROBOCO_AGENT_MODEL=gemini-2.5-pro" in cmd # Usage capture: per-agent data dir mounted + the entrypoint's usage file. assert host.data_dirs_ensured == ["be-dev-1"] assert "/host/data/gemini-usage/be-dev-1:/home/agent/.gemini-usage" in cmd assert "ROBOCO_GEMINI_USAGE_FILE=/home/agent/.gemini-usage/usage.json" 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. assert cmd[-1] == "roboco-agent-gemini:test" assert host.removed == ["roboco-agent-be-dev-1"] assert host.remove_stop_reasons == ["pre_spawn_stale_clear"] assert result == SpawnResult( instance_id="roboco-agent-be-dev-1", extra={"container_id": "cid", "model": "gemini-2.5-pro"}, ) async def test_gemini_spawn_adds_compose_labels_before_image( monkeypatch: pytest.MonkeyPatch, ) -> None: """When the orchestrator resolves its own compose project, the sibling agent container carries it too (UGOS grouping + `down --remove-orphans`) — and the labels land BEFORE the image, never after (docker parses anything past the image as the container command, not a flag).""" async def _fake_label_args(service: str) -> list[str]: return ["--label", f"com.docker.compose.service={service}"] monkeypatch.setattr( "roboco.llm.providers.gemini.compose_label_args", _fake_label_args ) host = _FakeHost() provider = GeminiCliProvider(host, image="roboco-agent-gemini:test") 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 "com.docker.compose.service=be-dev-1" in cmd assert cmd.index("com.docker.compose.service=be-dev-1") < cmd.index( "roboco-agent-gemini:test" ) assert cmd[-1] == "roboco-agent-gemini:test" async def test_gemini_spawn_mounts_auth_when_present( _isolate_gemini_auth: Path, ) -> None: (_isolate_gemini_auth / "oauth_creds.json").write_text("{}", encoding="utf-8") host = _FakeHost() provider = GeminiCliProvider(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_gemini_auth}:/home/agent/.gemini-auth-ro:ro" assert expected in cmd async def test_gemini_spawn_omits_auth_mount_when_absent() -> None: # No oauth_creds.json in the (tmp) GEMINI_AUTH_HOST_PATH -> no mount, no crash. host = _FakeHost() provider = GeminiCliProvider(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/.gemini-auth-ro" in c for c in cmd) async def test_gemini_spawn_warns_when_auth_absent( caplog: pytest.LogCaptureFixture, ) -> None: """A missing host oauth_creds.json must not be silent — the spawn is doomed to exit 41, so the operator gets a spawn-time WARNING naming the missing file and the remediation.""" caplog.set_level("WARNING", logger="roboco.llm.providers.gemini") host = _FakeHost() provider = GeminiCliProvider(host) with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())): await provider.spawn(_config()) warnings = [r for r in caplog.records if r.levelname == "WARNING"] assert warnings, "expected a spawn-time WARNING for the missing oauth_creds.json" msg = warnings[0].getMessage() assert "oauth_creds.json" in msg assert "gemini" in msg # names the remediation async def test_gemini_spawn_prompt_is_injection_safe() -> None: host = _FakeHost() provider = GeminiCliProvider(host) nasty = "--model evil --approval-mode yolo-override" with patch( "asyncio.create_subprocess_exec", AsyncMock(return_value=_proc()) ) as exec_mock: await provider.spawn(_config(), initial_prompt=nasty) cmd = list(exec_mock.call_args.args) # Passed only as an env value, never as a bare argv token. assert f"ROBOCO_INITIAL_PROMPT={nasty}" in cmd assert nasty not in cmd async def test_gemini_spawn_raises_on_docker_failure() -> None: provider = GeminiCliProvider(_FakeHost()) with ( patch( "asyncio.create_subprocess_exec", AsyncMock(return_value=_proc(returncode=1, stderr=b"boom")), ), pytest.raises(ProviderError, match="boom"), ): await provider.spawn(_config()) async def test_gemini_remove_delegates_to_host() -> None: host = _FakeHost() provider = GeminiCliProvider(host) await provider.remove("roboco-agent-be-dev-1") assert host.removed == ["roboco-agent-be-dev-1"]