mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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"
|
||||
Reference in New Issue
Block a user