Files
roboco/docker/scripts/gemini-cli-agent-entrypoint.sh
T

126 lines
6.7 KiB
Bash
Raw Normal View History

#!/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"