From 891da2372dba2423dc820be2bb795d298b7fc286 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 16 May 2026 20:31:11 +0000 Subject: [PATCH] fix(cron_vm): publish from agent-shin fork + harden systemd unit The cron host has no write access to BerriAI/litellm-docs by design. PRs now open from a long-lived fork at agent-shin/litellm-docs: - run_daily.sh validates AGENT_SHIN_GITHUB_TOKEN up front (failing 30 min into a run because the env file is missing one line is wasted spend). - The pre-commit shim adds a transient `fork` remote with the token embedded in the URL, force-pushes the branch, then removes the remote so the token never lives on disk. - `gh pr create --head agent-shin:` opens the cross-repo PR with GH_TOKEN scoped to AGENT_SHIN_GITHUB_TOKEN. A second `gh pr edit --add-reviewer` runs under GITHUB_TOKEN (mateo-berri's PAT) because agent-shin's PAT lacks RequestReviewsByLogin permission on the upstream repo. - PR_REVIEWERS env var (default `mateo-berri`) controls who gets auto-tagged; empty disables. Also bring litellm-compat-matrix.service to working state: - Hardcode `/home/mateo` paths everywhere %h was used. systemd expands %h against the *manager's* home (/root for PID 1) in *system* units, not against the User= directive. The mismatch made ReadWritePaths point at /root/.cache and the namespace setup failed with status=226/NAMESPACE before run_daily.sh ever started. - Explicit Environment=PATH so `uv` and `claude` under ~/.local/bin are visible to the up-front command-presence check; systemd's default PATH excludes them. - Expand ReadWritePaths to include ~/.claude (CLI per-session state) and ~/.config/gh (gh host config fallback); both are written under ProtectHome=read-only. env.example refreshed: drop AWS_ACCESS_KEY_ID/SECRET + GOOGLE_APPLICATION_CREDENTIALS in favor of AWS_BEARER_TOKEN_BEDROCK and ADC via the VM's metadata server; document AGENT_SHIN_GITHUB_TOKEN, FORK_OWNER/FORK_REPO overrides, and VERTEXAI_LOCATION=global. --- .../cron_vm/litellm-compat-matrix.env.example | 30 +++++-- .../cron_vm/litellm-compat-matrix.service | 47 +++++++++-- tests/claude_code/cron_vm/run_daily.sh | 83 +++++++++++++++++-- 3 files changed, 139 insertions(+), 21 deletions(-) diff --git a/tests/claude_code/cron_vm/litellm-compat-matrix.env.example b/tests/claude_code/cron_vm/litellm-compat-matrix.env.example index 861057e0dfd..11633810533 100644 --- a/tests/claude_code/cron_vm/litellm-compat-matrix.env.example +++ b/tests/claude_code/cron_vm/litellm-compat-matrix.env.example @@ -8,22 +8,36 @@ # Anthropic ANTHROPIC_API_KEY= -# Bedrock (invoke + converse columns) -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= +# Bedrock (invoke + converse columns). +# Use Anthropic's Bedrock API-key passthrough (long-lived bearer token). +# No AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY required for the matrix -- +# both the LiteLLM invoke and converse routes pick up +# AWS_BEARER_TOKEN_BEDROCK when present. +AWS_BEARER_TOKEN_BEDROCK= AWS_REGION_NAME=us-east-1 -# Vertex AI +# Vertex AI. +# On the GCP VM, the default service-account ADC from the metadata server +# is used -- no JSON key file is needed. If you ever need to run outside +# GCP, also export GOOGLE_APPLICATION_CREDENTIALS=/path/to/sa.json. VERTEXAI_PROJECT= -VERTEXAI_LOCATION=us-east5 -GOOGLE_APPLICATION_CREDENTIALS=/etc/litellm-compat-matrix.gcp.json +VERTEXAI_LOCATION=global # Microsoft Foundry (Azure column) AZURE_FOUNDRY_API_KEY= AZURE_FOUNDRY_API_BASE= +# REQUIRED for publishing: PAT for the `agent-shin` user, used to push +# the daily compat-matrix branch to its fork (agent-shin/litellm-docs) +# and open the cross-repo PR against BerriAI/litellm-docs. Scopes: +# classic `repo` + `workflow`, or fine-grained on agent-shin/litellm-docs +# with Contents:RW + Pull requests:RW + Workflows:RW. +# Skip by setting SKIP_PUBLISH=1 (publishes nothing; only writes the +# matrix JSON locally). +AGENT_SHIN_GITHUB_TOKEN= + # Optional: lifts the unauthenticated rate limit on the GitHub Releases -# API used by `resolver.py`. Not required. +# API used by `resolver.py`. Any token works (read-only). Not required. # GITHUB_TOKEN= # Optional overrides; defaults are sensible for the cron VM. @@ -32,3 +46,5 @@ AZURE_FOUNDRY_API_BASE= # DOCS_REPO=BerriAI/litellm-docs # DOCS_BRANCH=main # DOCS_TARGET_PATH=src/data/compatibility-matrix.json +# FORK_OWNER=agent-shin +# FORK_REPO=agent-shin/litellm-docs diff --git a/tests/claude_code/cron_vm/litellm-compat-matrix.service b/tests/claude_code/cron_vm/litellm-compat-matrix.service index abbd694c17e..5a93df66cc9 100644 --- a/tests/claude_code/cron_vm/litellm-compat-matrix.service +++ b/tests/claude_code/cron_vm/litellm-compat-matrix.service @@ -13,9 +13,13 @@ # sudo systemctl daemon-reload # sudo systemctl enable --now litellm-compat-matrix.timer # -# The %h specifier expands to the runtime user's home directory, which -# avoids hard-coding /home/mateo into a unit that should be portable -# across operator handoffs. The runtime user (`User=mateo`) must: +# Paths are hard-coded to /home/mateo rather than using systemd's %h +# specifier. Why: in *system* units (this one), %h is expanded at +# parse time against the *manager's* home -- which is /root for PID 1 +# -- and *not* against the User= directive. That mismatch makes +# ReadWritePaths point at /root/.cache (which doesn't exist), causing +# the namespace setup to fail with status=226/NAMESPACE before the +# script ever runs. The runtime user (`User=mateo`) must: # # * have a checkout of `BerriAI/litellm` at `~/litellm/litellm` so the # publisher module is importable; @@ -28,7 +32,7 @@ [Unit] Description=Claude Code compatibility-matrix populator (oneshot) -Documentation=file://%h/litellm/litellm/tests/claude_code/cron_vm/README.md +Documentation=file:///home/mateo/litellm/litellm/tests/claude_code/cron_vm/README.md Wants=network-online.target After=network-online.target @@ -41,9 +45,21 @@ Group=mateo # is the standard `KEY=value` one line per env var. EnvironmentFile=-/etc/litellm-compat-matrix.env -WorkingDirectory=%h/litellm/litellm +# systemd starts with a minimal PATH (~/usr/local/bin:/usr/bin:/bin). +# `uv` and `claude` are installed under the runtime user's `~/.local/bin` +# so we have to prepend it explicitly; otherwise run_daily.sh fails at +# the up-front command-presence check. +Environment=PATH=/home/mateo/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin -ExecStart=%h/litellm/litellm/tests/claude_code/cron_vm/run_daily.sh +# `HOME` is auto-set to /home/mateo when User=mateo is honored, but be +# explicit so anything that reads $HOME (e.g. uv's cache lookup, the +# claude CLI's per-session dir) sees the right value even if a future +# refactor flips DynamicUser= or PrivateUsers= on. +Environment=HOME=/home/mateo + +WorkingDirectory=/home/mateo/litellm/litellm + +ExecStart=/home/mateo/litellm/litellm/tests/claude_code/cron_vm/run_daily.sh # 90 minutes is generous: cold runs do `git clone` + `uv sync` of a new # tag's lockfile, which can take a couple of minutes on a 2-vCPU VM, @@ -57,10 +73,27 @@ Restart=no # Security hardening: the populator only reads the litellm checkout and # the env-file; everything else it writes lives in either the worktree # (managed) or `/tmp` (cleaned up by tempfile). +# +# ReadWritePaths whitelist: +# * litellm-cron-worktree - the long-lived stable-tag checkout + +# its `.venv` (`uv sync` rewrites every +# run) + `.uv-bin` (pinned `uv` binary +# cache). +# * .cache - uv's wheel cache (~/.cache/uv) so we +# don't redownload pinned deps each run. +# * .claude - `claude` CLI's per-session state under +# `~/.claude/projects//`; created +# on every `claude --print` invocation. +# * .config/gh - `gh` CLI host config; technically not +# needed when we pass GH_TOKEN inline, +# but cheap to whitelist and prevents +# future regressions if a code path +# ever falls back to the host config. +# * /tmp - mktemp -d workdir + proxy logs. NoNewPrivileges=true ProtectSystem=strict ProtectHome=read-only -ReadWritePaths=%h/litellm-cron-worktree %h/.cache /tmp +ReadWritePaths=/home/mateo/litellm-cron-worktree /home/mateo/.cache /home/mateo/.claude /home/mateo/.config/gh /tmp PrivateTmp=true [Install] diff --git a/tests/claude_code/cron_vm/run_daily.sh b/tests/claude_code/cron_vm/run_daily.sh index c64872867a5..37589567c4f 100755 --- a/tests/claude_code/cron_vm/run_daily.sh +++ b/tests/claude_code/cron_vm/run_daily.sh @@ -40,6 +40,11 @@ DOCS_BRANCH="${DOCS_BRANCH:-main}" DOCS_TARGET_PATH="${DOCS_TARGET_PATH:-src/data/compatibility-matrix.json}" SKIP_PUBLISH="${SKIP_PUBLISH:-0}" PYTEST_K="${PYTEST_K:-}" +# Comma-separated GitHub usernames to request a review from on every PR. +# Reviewers must have at least read access to ${DOCS_REPO}. PR-author +# (agent-shin) has implicit rights to request reviews from anyone with +# read access, so no extra token scope is needed. Set to empty to skip. +PR_REVIEWERS="${PR_REVIEWERS:-mateo-berri}" POPULATOR_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORKDIR="$(mktemp -d -t litellm-compat-matrix.XXXXXX)" @@ -89,6 +94,15 @@ for cmd in git uv gh jq curl claude; do command -v "${cmd}" >/dev/null 2>&1 || die "missing required command: ${cmd}" done +# Publishing is from a fork (agent-shin/litellm-docs) so neither the cron +# host nor the bot identity needs write access to BerriAI/litellm-docs. We +# require the fork token up front -- failing 30 minutes into a run because +# the env file is missing one line is a waste of CI quota. +if [[ "${SKIP_PUBLISH}" != "1" ]]; then + [[ -n "${AGENT_SHIN_GITHUB_TOKEN:-}" ]] \ + || die "AGENT_SHIN_GITHUB_TOKEN required to open PRs from agent-shin/litellm-docs (or set SKIP_PUBLISH=1)" +fi + # --------------------------------------------------------------------------- # 1. Resolve versions # --------------------------------------------------------------------------- @@ -340,6 +354,8 @@ fi DATE_UTC="$(date -u +%Y-%m-%d)" BRANCH_NAME="compat-matrix/${LITELLM_VERSION}-${CLAUDE_CODE_VERSION}-${DATE_UTC}" DOCS_CLONE="${WORKDIR}/litellm-docs" +FORK_OWNER="${FORK_OWNER:-agent-shin}" +FORK_REPO="${FORK_REPO:-${FORK_OWNER}/litellm-docs}" log "cloning ${DOCS_REPO}@${DOCS_BRANCH}" gh repo clone "${DOCS_REPO}" "${DOCS_CLONE}" -- --depth 1 --branch "${DOCS_BRANCH}" @@ -369,9 +385,24 @@ EOF )" git commit -m "${COMMIT_MSG}" -# --force-with-lease so a same-day rerun fast-forwards (or rebases) the -# existing branch without clobbering a maintainer's manual fixup. -git push --force-with-lease --set-upstream origin "${BRANCH_NAME}" +# Push to the fork (agent-shin/litellm-docs), not to BerriAI/litellm-docs. +# The cron host has no write access to BerriAI/litellm-docs by design -- +# only agent-shin's PAT does, and only over its own fork. The temp remote +# carries the token in its URL, so we add it, push, then immediately +# remove it so the token never lingers in ${DOCS_CLONE}/.git/config. +# (${DOCS_CLONE} is also rm -rf'd by the cleanup trap on exit.) +# +# Plain --force (not --force-with-lease) is acceptable here: the fork +# branch is bot-owned, only this script ever writes to it, and runs are +# serialized by the systemd timer. --force-with-lease would require a +# fetch to populate the remote-tracking ref before each push and adds +# no safety in this single-writer setup. +FORK_PUSH_URL="https://x-access-token:${AGENT_SHIN_GITHUB_TOKEN}@github.com/${FORK_REPO}.git" +git remote remove fork 2>/dev/null || true +git remote add fork "${FORK_PUSH_URL}" +git push --force --set-upstream fork "${BRANCH_NAME}" +git remote remove fork +unset FORK_PUSH_URL # Per-feature status table for the PR body. Reviewers triage from this. PR_FEATURE_TABLE="$(jq -r ' @@ -402,13 +433,26 @@ Generated by \`tests/claude_code/cron_vm/run_daily.sh\`. Close without merging i EOF )" -log "opening PR" +log "opening PR from ${FORK_OWNER}:${BRANCH_NAME} -> ${DOCS_REPO}:${DOCS_BRANCH}" +# GH_TOKEN here is scoped to this single subshell so we don't bleed the +# fork token into the rest of the script (release-listing earlier uses +# ${GITHUB_TOKEN}, which may be a different identity). gh's --head accepts +# `OWNER:BRANCH` for cross-repo PRs from a fork. +# +# Reviewer assignment is done in a *separate* call below: as the PR +# author from a fork, agent-shin has no write/triage access on +# ${DOCS_REPO} and the `RequestReviewsByLogin` GraphQL mutation +# (which backs `gh pr create --reviewer` and `gh pr edit --add-reviewer`) +# rejects with "does not have the correct permissions". We use the +# collaborator-scoped ${GITHUB_TOKEN} for that instead. Don't fold +# --reviewer into `gh pr create` here -- it would fail the whole +# create on the very first cron run. set +e PR_OUT="$( - gh pr create \ + GH_TOKEN="${AGENT_SHIN_GITHUB_TOKEN}" gh pr create \ --repo "${DOCS_REPO}" \ --base "${DOCS_BRANCH}" \ - --head "${BRANCH_NAME}" \ + --head "${FORK_OWNER}:${BRANCH_NAME}" \ --title "${PR_TITLE}" \ --body "${PR_BODY}" 2>&1 )" @@ -418,10 +462,35 @@ echo "${PR_OUT}" if [[ ${PR_EXIT} -ne 0 ]]; then if grep -q "a pull request for branch.*already exists" <<<"${PR_OUT}"; then - log "PR already exists for ${BRANCH_NAME}; updated branch in place" + log "PR already exists for ${FORK_OWNER}:${BRANCH_NAME}; updated branch in place" else die "gh pr create failed (exit ${PR_EXIT})" fi fi +# Request reviews from PR_REVIEWERS using the collaborator-scoped +# ${GITHUB_TOKEN} (mateo-berri's token, already provisioned for release +# listing). This is idempotent: `gh pr edit --add-reviewer` is a no-op +# on a user who's already in reviewRequests, and silently re-adds +# anyone whose prior review was dismissed -- so same-day reruns stay +# clean. Reviewer-add failures are non-fatal: the matrix JSON has +# already landed on the PR; the worst case is a manual ping. +if [[ -n "${PR_REVIEWERS}" ]]; then + if [[ -z "${GITHUB_TOKEN:-}" ]]; then + log "WARN: PR_REVIEWERS set but GITHUB_TOKEN missing -- cannot request reviews; skipping" + else + log "requesting reviews from: ${PR_REVIEWERS}" + set +e + GH_TOKEN="${GITHUB_TOKEN}" gh pr edit \ + "${FORK_OWNER}:${BRANCH_NAME}" \ + --repo "${DOCS_REPO}" \ + --add-reviewer "${PR_REVIEWERS}" 2>&1 | sed 's/^/ /' + REVIEWER_EXIT=${PIPESTATUS[0]} + set -e + if [[ ${REVIEWER_EXIT} -ne 0 ]]; then + log "WARN: gh pr edit --add-reviewer exited ${REVIEWER_EXIT} (non-fatal)" + fi + fi +fi + log "done"