feat(providers): Gemini CLI provider — ModelProvider.GEMINI (#660)

* feat(providers): Gemini CLI provider — ModelProvider.GEMINI

Mirrors the grok blueprint with source-verified divergences (all facts
pinned against google-gemini/gemini-cli @ 9681621c): no refresher
daemon — Google's refresh tokens are reusable, so the RO host mount is
COPIED to a writable container-local ~/.gemini and each container
refreshes in-process independently (the write-back crash risk on RO
never triggers); settings.json renders security.auth.selectedType
'oauth-personal', experimental.enableAgents=false (subagent ban),
autoConfigureMemory=false with a bounded heap; tool scoping rides the
tiered TOML Policy Engine (deny-only rules that yolo mode structurally
cannot beat); gemini -p with --output-format stream-json; usage parsed
from the run's own stdout stats — the adversarial pass caught the
parser reading the json-mode nested shape while the entrypoint runs
stream-json's FLAT shape (every real run would have priced $0 forever,
hidden by fixtures sharing the assumption) — now flat-primary with the
nested shape as cited fallback; rate-limit classified from structured
error.type only (model-echo immune), native exit 41 auth passthrough;
per-model pricing for the three GA models; migrations 084 (enum) + 085
(seed) complete the 082-085 finale chain. V1 excludes interactive
intake/secretary. Stack-merge required two behavior-preserving
complexity refactors in the shared park/usage plumbing (a park-pair
loop; a usage-reader dispatch dict).

* fix(providers): route gemini usage read through the containment barrier

Mirrors the codex/grok fix — _gemini_usage_json now delegates to
_read_usage_json_contained, so CodeQL's path-injection alert on the
gemini read is resolved by the same resolve-and-contain guard.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-23 03:53:21 +02:00
committed by GitHub
co-authored by Renn F
parent 13abb2ece0
commit 21d6730400
24 changed files with 2664 additions and 56 deletions
+48
View File
@@ -0,0 +1,48 @@
# Gemini (Google) Agent Image
# =============================================================================
# Runs Gemini through Google's official `gemini` CLI, authenticated by an OAuth
# login via a mounted ~/.gemini/oauth_creds.json — the parity analogue of the
# Claude Code path's mounted ~/.claude and the grok path's mounted ~/.grok (no
# metered API key). Reuses the base image's roboco venv + uv + the RoboCo MCP
# gateway servers, and the base image's Node.js 22 (the CLI needs node >= 20).
# The entrypoint copies the staged read-only OAuth credential into a writable
# ~/.gemini, renders ~/.gemini/settings.json + a Policy Engine TOML from the
# mounted mcp-config.json (see roboco.llm.providers.gemini_cli_config), and
# runs the CLI headless. One runtime image serves every role — role behaviour
# comes from the mounted system prompt / manifest / mcp-config, exactly as on
# the Claude/grok paths.
# =============================================================================
FROM roboco-agent-base
USER root
# Install the official Gemini CLI. Pinned — untrusted model output runs under
# it, so bump the version deliberately, never float (spike verified 0.52.0 at
# github.com/google-gemini/gemini-cli @ 9681621c). npm installs to the global
# node_modules the base image's Node 22 already resolves onto PATH.
ARG GEMINI_CLI_VERSION=0.52.0
RUN npm install -g "@google/gemini-cli@${GEMINI_CLI_VERSION}" \
&& npm cache clean --force \
&& rm -rf /root/.npm /tmp/* \
&& gemini --version
# Entrypoint: copy the staged OAuth credential into a writable ~/.gemini,
# render settings.json + policy TOML, then run gemini headless (overrides the
# base image's `claude` entrypoint). Owned by agent (mirrors the grok image).
COPY docker/scripts/gemini-cli-agent-entrypoint.sh /app/scripts/gemini-cli-agent-entrypoint.sh
RUN chmod 0755 /app/scripts/gemini-cli-agent-entrypoint.sh \
&& mkdir -p /home/agent/.gemini \
&& chown -R agent:agent /home/agent/.gemini
USER agent
LABEL role="gemini-cli-runtime"
LABEL description="Gemini (Google) agent runtime — Gemini Build via the official gemini CLI"
# advanced.autoConfigureMemory=false (rendered into settings.json) pins Node's
# heap sizing away from auto-detection against a shared host; this bounds it
# explicitly instead. Tunable per-deploy without a rebuild.
ENV NODE_OPTIONS="--max-old-space-size=2048"
ENTRYPOINT ["/app/scripts/gemini-cli-agent-entrypoint.sh"]
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# Entrypoint for the roboco-agent-gemini image (one-shot delivery roles).
#
# Runs an agent on Google's official `gemini` CLI, authenticated by an OAuth
# login via a mounted ~/.gemini/oauth_creds.json — the parity analogue of the
# Claude Code path's mounted ~/.claude and the grok path's mounted ~/.grok. The
# gateway, identity, and workspace are mounted by the orchestrator's shared
# container assembly (the same that wires Claude/grok); this entrypoint copies
# the staged OAuth credential into a writable ~/.gemini, renders the gemini
# runtime config from that mount, and runs the CLI headless.
set -euo pipefail
# The orchestrator mounts the host ~/.gemini DIRECTORY read-only at this
# staging path (roboco.llm.providers.gemini._append_gemini_auth_mount). Copy it
# into the image's own ~/.gemini (agent-owned, writable — see the Dockerfile)
# so the CLI's in-process OAuth refresh (google-auth-library) can write the
# refreshed token back locally: Google's refresh token is REUSABLE, so each
# container refreshing its OWN copy independently is safe (contrast grok's
# live-symlinked RO mount, which needs single-writer orchestrator-side
# serialization because xAI's refresh token is single-use — see
# roboco.llm.providers.gemini's module docstring). The host's copy is never
# touched.
AUTH_STAGING_DIR="/home/agent/.gemini-auth-ro"
if [ -d "$AUTH_STAGING_DIR" ]; then
cp -a "$AUTH_STAGING_DIR"/. /home/agent/.gemini/ 2>/dev/null || true
fi
# Auth preflight. Without a real OAuth credential the CLI would hang at an
# interactive consent prompt in a headless container (or refuse outright,
# depending on the auth-check path) — refuse fast instead: exit 41 (the CLI's
# own dedicated auth-failure code, so the orchestrator's exit classifier
# treats a missing credential identically to a real CLI auth rejection).
if [ ! -s /home/agent/.gemini/oauth_creds.json ]; then
echo "[gemini] OAuth credential missing at ~/.gemini/oauth_creds.json — refusing" \
"to run. Run 'gemini' interactively once on the host (or set" \
"ROBOCO_HOST_GEMINI_DIR at the directory holding oauth_creds.json) before" \
"spawning Gemini agents." >&2
exit 41
fi
# Render ~/.gemini/settings.json (mcpServers + selectedType/enableAgents/
# autoConfigureMemory) + GEMINI.md + the Policy Engine TOML. Run from /app so
# `python -m` resolves the INSTALLED roboco package: dev/doc/qa agents run at
# their workspace-clone cwd, whose own roboco/ dir would shadow it on the
# sys.path front (the ModuleNotFound lesson). The render reads
# ROBOCO_MCP_CONFIG + ROBOCO_AGENT_ID and writes the config files.
( cd /app && python -m roboco.llm.providers.gemini_cli_config )
# Prompt-injection guard (parity with the Claude UserPromptSubmit hook / the
# grok path): the task prompt is DATA, not instructions — refuse a poisoned
# one before the model sees it. Same patterns as
# docker/scripts/user-prompt-hook.sh; run from /app too.
if ! ( cd /app && python -m roboco.agent_sdk.prompt_guard "${ROBOCO_INITIAL_PROMPT:-}" ); then
echo "Refusing to run: task prompt matched a prompt-injection pattern." >&2
exit 1
fi
# MCP-dead-server mitigation. The Gemini CLI has no native fail-fast for a
# dead MCP server (a disconnected server just logs DISCONNECTED and the run
# continues, tool-less) — so this cheap out-of-band check (the same
# gateway-venv import probe the orchestrator's reaper uses,
# `_probe_gateway_health`) catches a corrupted /app/.venv BEFORE the run
# starts, exiting 52 (EX_CONFIG-style: config/environment broken, not a task
# failure) instead of burning a whole run against a tool-less gateway.
if ! /app/.venv/bin/python -c "import httpx, mcp" 2>/dev/null; then
echo "[gemini] MCP gateway venv is broken (httpx/mcp import failed) — refusing" \
"to run against a dead gateway." >&2
exit 52
fi
# Run the agent. The prompt comes from an env var (never an untrusted argv
# positional). `< /dev/null` keeps the headless run from blocking on stdin. We
# do NOT `exec`: the script regains control to classify the exit code +
# capture usage. `--output-format stream-json` + `tee` streams the run to the
# container's stdout LIVE (so `docker logs` shows the agent reasoning /
# answering in real time, parity with the Claude/grok paths' stream-json)
# while ALSO capturing it to RUN_LOG for the usage-stats / exit-classification
# reads below. stderr goes to ERR_LOG and is surfaced after the run.
RUN_LOG="/tmp/gemini-run.json"
ERR_LOG="/tmp/gemini-run.err"
# gemini_cli_config (rendered above) already wrote the per-role CLI flag
# tokens (today: just --approval-mode yolo — tool scoping lives in
# settings.json/policy TOML, not CLI flags) one per line to this file.
GEMINI_ARGS_FILE="${ROBOCO_GEMINI_ARGS_FILE:-/tmp/roboco-gemini-args}"
mapfile -t GEMINI_ARGS < "$GEMINI_ARGS_FILE"
set +e
gemini -p "${ROBOCO_INITIAL_PROMPT:-}" \
-m "${ROBOCO_AGENT_MODEL:-gemini-2.5-pro}" \
--output-format stream-json \
"${GEMINI_ARGS[@]}" \
< /dev/null 2> "$ERR_LOG" | tee "$RUN_LOG"
run_rc=${PIPESTATUS[0]}
set -e
# stdout already streamed live via tee; surface stderr (tool calls / errors) too.
[ -s "$ERR_LOG" ] && cat "$ERR_LOG" >&2
# Capture token usage from the run's own captured stdout (no session-file
# scraping — Gemini reports per-model stats directly, unlike grok). Writes a
# usage.json the orchestrator reads back at finalize. Best-effort; never fails
# the run. Run from /app for the same module-resolution reason as the render
# above.
( cd /app && ROBOCO_GEMINI_RUN_LOG="$RUN_LOG" \
python -m roboco.llm.providers.gemini_cli_usage ) || true
# Exit-code classification. 41 (auth) is the CLI's own dedicated exit code and
# passes through unchanged. A quota/rate-limit error has NO dedicated CLI exit
# code — it falls to the CLI's generic 1 — so this remaps it to 75 (EX_TEMPFAIL)
# by parsing the run's captured JSON for a quota-error `error.type`
# (TerminalQuotaError / RetryableQuotaError), the parity analogue of grok's
# text-grep exit-75 detector. The orchestrator parks the GEMINI provider on 75
# instead of the dispatcher respawning the same task every tick.
classified_rc=$(cd /app && ROBOCO_GEMINI_RUN_LOG="$RUN_LOG" \
ROBOCO_GEMINI_CLI_EXIT_CODE="$run_rc" \
python -m roboco.llm.providers.gemini_cli_usage --classify-exit)
if [ "$classified_rc" != "$run_rc" ]; then
echo "[gemini] exit $run_rc reclassified to $classified_rc (quota/rate-limit" \
"detected in the run output) — the orchestrator parks the provider; the" \
"task is retried when the limit lifts." >&2
fi
# A graceful exit without a terminal verb is handled server-side by the
# orchestrator (_handle_stopped_container substitutes the still-owned task) —
# the gemini-cli runtime needs no in-container SDK server for that.
exit "$classified_rc"