diff --git a/workspace/Dockerfile b/workspace/Dockerfile index 7306db35..8b1fc795 100644 --- a/workspace/Dockerfile +++ b/workspace/Dockerfile @@ -56,8 +56,15 @@ RUN chmod +x /usr/local/bin/gh COPY scripts/molecule-git-token-helper.sh ./scripts/ RUN chmod +x ./scripts/molecule-git-token-helper.sh +# Copy the background token refresh daemon. Runs as a background process +# started by entrypoint.sh — refreshes gh CLI auth and the credential +# helper cache every 45 min so tokens never expire mid-operation. +COPY scripts/molecule-gh-token-refresh.sh ./scripts/ +RUN chmod +x ./scripts/molecule-gh-token-refresh.sh + # Dirs and permissions -RUN mkdir -p /workspace /plugins /home/agent/.claude /home/agent/.config /home/agent/.local && \ +RUN mkdir -p /workspace /plugins /home/agent/.claude /home/agent/.config /home/agent/.local \ + /home/agent/.molecule-token-cache && \ chown -R agent:agent /app /home/agent /workspace # Install gosu for clean root → agent user handoff in entrypoint. diff --git a/workspace/entrypoint.sh b/workspace/entrypoint.sh index 2c257a28..da36fc4e 100644 --- a/workspace/entrypoint.sh +++ b/workspace/entrypoint.sh @@ -42,8 +42,51 @@ if [ "$(id -u)" = "0" ]; then chown -R agent:agent /root/.claude /home/agent/.claude 2>/dev/null ln -sfn /root/.claude/sessions /home/agent/.claude/sessions fi + + # --- GitHub credential helper setup (issue #547 / #613) --- + # Configure git to use the molecule credential helper for github.com. + # This runs as root so the global gitconfig is written before we drop + # to agent. The helper fetches fresh GitHub App installation tokens + # from the platform API, with caching and env-var fallback. + if [ -x /app/scripts/molecule-git-token-helper.sh ]; then + # Set credential helper for github.com only (not all hosts). + # The '!' prefix tells git to run the command as a shell command. + git config --global "credential.https://github.com.helper" \ + "!/app/scripts/molecule-git-token-helper.sh" + # Disable other credential helpers for github.com to avoid conflicts. + git config --global "credential.https://github.com.useHttpPath" true + # Move gitconfig to agent's home so it takes effect after gosu. + if [ -f /root/.gitconfig ]; then + cp /root/.gitconfig /home/agent/.gitconfig + chown agent:agent /home/agent/.gitconfig + fi + fi + # Create the token cache directory for the agent user. + mkdir -p /home/agent/.molecule-token-cache + chown agent:agent /home/agent/.molecule-token-cache + chmod 700 /home/agent/.molecule-token-cache + exec gosu agent "$0" "$@" fi # Now running as agent (uid 1000) + +# --- Start background token refresh daemon --- +# Keeps gh CLI and git credentials fresh across the 60-min token TTL. +# Runs in the background; entrypoint continues to exec molecule-runtime. +if [ -x /app/scripts/molecule-gh-token-refresh.sh ]; then + nohup /app/scripts/molecule-gh-token-refresh.sh > /dev/null 2>&1 & +fi + +# --- Initial gh auth setup --- +# If GITHUB_TOKEN or GH_TOKEN is set (injected at provision time), +# authenticate gh CLI with it so it works immediately (before the first +# background refresh fires). The background daemon will replace this +# with a fresh token within ~60s of boot. +if [ -n "${GITHUB_TOKEN:-}" ]; then + echo "${GITHUB_TOKEN}" | gh auth login --hostname github.com --with-token 2>/dev/null || true +elif [ -n "${GH_TOKEN:-}" ]; then + echo "${GH_TOKEN}" | gh auth login --hostname github.com --with-token 2>/dev/null || true +fi + exec molecule-runtime "$@" diff --git a/workspace/scripts/molecule-gh-token-refresh.sh b/workspace/scripts/molecule-gh-token-refresh.sh new file mode 100755 index 00000000..87c4d8a1 --- /dev/null +++ b/workspace/scripts/molecule-gh-token-refresh.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# molecule-gh-token-refresh.sh — background daemon that keeps GitHub +# credentials fresh inside Molecule AI workspace containers. +# +# Runs as a background process started by entrypoint.sh. Every +# REFRESH_INTERVAL_SEC (default 45 min = 2700s) it calls the credential +# helper's _refresh_gh action which: +# 1. Fetches a fresh installation token from the platform API +# 2. Updates the local cache (used by git credential helper) +# 3. Runs `gh auth login --with-token` so `gh` CLI stays authenticated +# 4. Writes ~/.gh_token for any scripts that read it +# +# The daemon logs to stderr (captured by Docker) and is designed to be +# fire-and-forget — if a single refresh fails, it logs the error and +# retries on the next interval. The credential helper itself has a +# fallback chain (cache > API > env var) so a missed refresh is not +# immediately fatal. +# +# Usage (from entrypoint.sh): +# nohup /app/scripts/molecule-gh-token-refresh.sh & +# +set -uo pipefail + +HELPER_SCRIPT="/app/scripts/molecule-git-token-helper.sh" +REFRESH_INTERVAL_SEC="${TOKEN_REFRESH_INTERVAL_SEC:-2700}" # 45 min + +log() { + echo "[molecule-gh-token-refresh] $(date -u '+%Y-%m-%dT%H:%M:%SZ') $*" >&2 +} + +# Wait a short time before the first refresh to let the container finish +# booting and .auth_token to be written by the runtime's register call. +INITIAL_DELAY_SEC="${TOKEN_REFRESH_INITIAL_DELAY_SEC:-60}" +log "starting (interval=${REFRESH_INTERVAL_SEC}s, initial_delay=${INITIAL_DELAY_SEC}s)" +sleep "${INITIAL_DELAY_SEC}" + +# Initial refresh — prime the cache + gh auth immediately after boot. +log "initial token refresh" +if bash "${HELPER_SCRIPT}" _refresh_gh 2>&1; then + log "initial refresh succeeded" +else + log "initial refresh failed (will retry in ${REFRESH_INTERVAL_SEC}s)" +fi + +# Steady-state loop. +while true; do + sleep "${REFRESH_INTERVAL_SEC}" + log "periodic token refresh" + if bash "${HELPER_SCRIPT}" _refresh_gh 2>&1; then + log "refresh succeeded" + else + log "refresh failed (will retry in ${REFRESH_INTERVAL_SEC}s)" + fi +done diff --git a/workspace/scripts/molecule-git-token-helper.sh b/workspace/scripts/molecule-git-token-helper.sh index 84753422..e79bc14a 100755 --- a/workspace/scripts/molecule-git-token-helper.sh +++ b/workspace/scripts/molecule-git-token-helper.sh @@ -2,21 +2,22 @@ # molecule-git-token-helper.sh — git credential helper for GitHub App tokens # # Fetches a fresh GitHub App installation token from the Molecule AI -# platform endpoint GET /admin/github-installation-token on every git -# push/fetch, so workspace containers never use an expired GH_TOKEN after -# the ~60 min GitHub App token TTL. +# platform endpoint and caches it locally (~50 min), so workspace +# containers never use an expired GH_TOKEN after the ~60 min GitHub App +# token TTL. The cache avoids hitting the platform API on every git +# operation (push/fetch/clone). # -# # Setup (called once at provision time or initial_prompt) +# # Setup (called once at container boot by entrypoint.sh) # # git config --global \ # "credential.https://github.com.helper" \ -# "!/workspace/scripts/molecule-git-token-helper.sh" +# "!/app/scripts/molecule-git-token-helper.sh" # # # How git calls this helper # # git passes the action as the first positional arg. The protocol is: # get → output credentials on stdout (we handle this) -# store → persist credentials (no-op — we never cache) +# store → persist credentials (no-op — we never cache via git) # erase → revoke credentials (no-op — platform manages lifecycle) # # On `get`, git reads key=value pairs terminated by an empty line. @@ -32,27 +33,47 @@ # on first /registry/register). Workspace env var PLATFORM_URL defaults # to http://platform:8080. # -# # Fallback +# # Caching # -# If the platform endpoint is unreachable (e.g. network partition) or -# returns non-200, the script exits 1 without printing credentials so git -# will fall through to the next helper in the chain (if any). This -# preserves the operator's fallback PAT from .env if present. +# Tokens are cached at ${CACHE_DIR}/gh_installation_token with a +# companion ${CACHE_DIR}/gh_installation_token_expiry file containing +# the epoch-seconds expiry. Cache TTL is ~50 min (TOKEN_CACHE_TTL_SEC). +# If the cache is fresh, we return immediately without calling the API. # -# # gh CLI re-auth (30-min cron) +# # Fallback chain # -# To also fix `gh` CLI auth, run this from a workspace cron prompt: +# 1. Return cached token if not expired. +# 2. Fetch fresh token from platform API. +# 3. If platform is unreachable, fall back to GITHUB_TOKEN / GH_TOKEN +# env var (set at container start, valid for up to 60 min). +# 4. If all fail, exit 1 so git falls through to the next credential +# helper in the chain (if any). # -# token=$(bash /workspace/scripts/molecule-git-token-helper.sh _fetch_token) -# echo "$token" | gh auth login --with-token +# # gh CLI integration # -# (The _fetch_token private action returns only the raw token string.) +# Use the _refresh_gh action to atomically refresh both the cache and +# gh CLI auth: +# +# bash /app/scripts/molecule-git-token-helper.sh _refresh_gh +# +# This is called by molecule-gh-token-refresh.sh (the background daemon) +# every 45 min. # set -euo pipefail PLATFORM_URL="${PLATFORM_URL:-http://host.docker.internal:8080}" CONFIGS_DIR="${CONFIGS_DIR:-/configs}" TOKEN_FILE="${CONFIGS_DIR}/.auth_token" + +# Cache location — writable by agent user +CACHE_DIR="${HOME:=/home/agent}/.molecule-token-cache" +CACHE_TOKEN_FILE="${CACHE_DIR}/gh_installation_token" +CACHE_EXPIRY_FILE="${CACHE_DIR}/gh_installation_token_expiry" + +# Cache lifetime: 50 min = 3000 sec. Installation tokens last ~60 min; +# 50 min gives a 10-min safety margin for clock skew + in-flight ops. +TOKEN_CACHE_TTL_SEC=3000 + # #1068: use workspace-scoped path (WorkspaceAuth) instead of admin path # (AdminAuth rejects workspace bearer tokens since PR #729). WORKSPACE_ID="${WORKSPACE_ID:-}" @@ -62,18 +83,59 @@ else ENDPOINT="${PLATFORM_URL}/admin/github-installation-token" fi -# _fetch_token — internal helper; also callable directly from cron. -# Outputs the raw token string on success; exits non-zero on failure. -_fetch_token() { +# _now_epoch — portable epoch-seconds (works on both GNU and BusyBox date). +_now_epoch() { + date +%s +} + +# _read_cache — output cached token if still valid; return 1 if stale/missing. +_read_cache() { + if [ ! -f "${CACHE_TOKEN_FILE}" ] || [ ! -f "${CACHE_EXPIRY_FILE}" ]; then + return 1 + fi + expiry=$(cat "${CACHE_EXPIRY_FILE}" 2>/dev/null | tr -d '[:space:]') + if [ -z "${expiry}" ]; then + return 1 + fi + now=$(_now_epoch) + if [ "${now}" -ge "${expiry}" ]; then + return 1 + fi + token=$(cat "${CACHE_TOKEN_FILE}" 2>/dev/null | tr -d '[:space:]') + if [ -z "${token}" ]; then + return 1 + fi + echo "${token}" + return 0 +} + +# _write_cache — atomically persist token + expiry. +_write_cache() { + local token="$1" + mkdir -p "${CACHE_DIR}" + chmod 700 "${CACHE_DIR}" 2>/dev/null || true + now=$(_now_epoch) + expiry=$((now + TOKEN_CACHE_TTL_SEC)) + # Write atomically via tmp + mv to avoid partial reads. + printf '%s' "${token}" > "${CACHE_TOKEN_FILE}.tmp" + printf '%s' "${expiry}" > "${CACHE_EXPIRY_FILE}.tmp" + mv -f "${CACHE_TOKEN_FILE}.tmp" "${CACHE_TOKEN_FILE}" + mv -f "${CACHE_EXPIRY_FILE}.tmp" "${CACHE_EXPIRY_FILE}" + chmod 600 "${CACHE_TOKEN_FILE}" "${CACHE_EXPIRY_FILE}" 2>/dev/null || true +} + +# _fetch_token_from_api — hit the platform endpoint. +# Outputs the raw token string on success; returns non-zero on failure. +_fetch_token_from_api() { if [ ! -f "${TOKEN_FILE}" ]; then echo "[molecule-git-token-helper] .auth_token not found at ${TOKEN_FILE}" >&2 - exit 1 + return 1 fi bearer=$(cat "${TOKEN_FILE}" | tr -d '[:space:]') if [ -z "${bearer}" ]; then echo "[molecule-git-token-helper] .auth_token is empty" >&2 - exit 1 + return 1 fi response=$(curl -sf \ @@ -82,19 +144,48 @@ _fetch_token() { --max-time 10 \ "${ENDPOINT}" 2>&1) || { echo "[molecule-git-token-helper] platform request failed: ${response}" >&2 - exit 1 + return 1 } # Parse {"token":"ghs_...","expires_at":"..."} with sed (no jq dependency). token=$(echo "${response}" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p') if [ -z "${token}" ]; then echo "[molecule-git-token-helper] empty token in platform response: ${response}" >&2 - exit 1 + return 1 fi echo "${token}" } +# _fetch_token — return a fresh token using cache > API > env fallback chain. +# Outputs the raw token string on success; exits non-zero if all sources fail. +_fetch_token() { + # 1. Try cache first. + cached=$(_read_cache) && { + echo "${cached}" + return 0 + } + + # 2. Fetch from platform API. + api_token=$(_fetch_token_from_api 2>/dev/null) && { + _write_cache "${api_token}" + echo "${api_token}" + return 0 + } + + # 3. Fall back to env var (set at container start, may be stale but + # better than nothing for the first ~60 min of container life). + env_token="${GITHUB_TOKEN:-${GH_TOKEN:-}}" + if [ -n "${env_token}" ]; then + echo "[molecule-git-token-helper] API unreachable, falling back to env GITHUB_TOKEN" >&2 + echo "${env_token}" + return 0 + fi + + echo "[molecule-git-token-helper] all token sources exhausted" >&2 + return 1 +} + ACTION="${1:-get}" case "${ACTION}" in @@ -109,9 +200,33 @@ case "${ACTION}" in # No-op — the platform manages token lifecycle. ;; _fetch_token) - # Private action for cron-based gh auth login --with-token. + # Return raw token (cache > API > env fallback). _fetch_token ;; + _refresh_gh) + # Refresh cache AND update gh CLI auth in one shot. + # Called by molecule-gh-token-refresh.sh background daemon. + # Force-bypass cache to get a definitely fresh token. + api_token=$(_fetch_token_from_api) || { + echo "[molecule-git-token-helper] _refresh_gh: API fetch failed" >&2 + exit 1 + } + _write_cache "${api_token}" + # Update gh CLI auth — gh auth login reads token from stdin. + echo "${api_token}" | gh auth login --hostname github.com --with-token 2>/dev/null || { + echo "[molecule-git-token-helper] _refresh_gh: gh auth login failed (non-fatal)" >&2 + } + # Also update GH_TOKEN file for scripts that source it. + gh_token_file="${HOME}/.gh_token" + printf '%s' "${api_token}" > "${gh_token_file}.tmp" + mv -f "${gh_token_file}.tmp" "${gh_token_file}" + chmod 600 "${gh_token_file}" 2>/dev/null || true + echo "[molecule-git-token-helper] _refresh_gh: token refreshed successfully" >&2 + ;; + _invalidate_cache) + # Force next call to hit the API (useful after a 401). + rm -f "${CACHE_TOKEN_FILE}" "${CACHE_EXPIRY_FILE}" 2>/dev/null + ;; *) echo "[molecule-git-token-helper] unknown action: ${ACTION}" >&2 exit 1