feat(workspace): 45-min gh-token refresh daemon + credential helper cache

Extracted from the now-closed PR #1664 (Molecule-AI/molecule-core).

- New scripts/molecule-gh-token-refresh.sh background daemon — every
  45 min (TOKEN_REFRESH_INTERVAL_SEC) calls the credential helper's
  _refresh_gh action to keep both gh CLI auth and the on-disk cache
  fresh through the GitHub App installation token's ~60 min TTL.
- scripts/molecule-git-token-helper.sh rewritten with a ~50 min
  on-disk cache (${CACHE_DIR}/gh_installation_token + _expiry
  companion file), a cache > API > env-var fallback chain, a new
  _refresh_gh action (invoked by the daemon above), a _invalidate_cache
  action, and path references flipped from /workspace/scripts/... to
  /app/scripts/... to match the runtime image layout.
- Dockerfile copies the new refresh daemon and extends mkdir to
  create /home/agent/.molecule-token-cache at build time.
- entrypoint.sh configures the git credential helper for github.com
  while still root (so the global gitconfig is written before the
  gosu handoff), creates + chowns the token cache dir, then as agent
  starts the refresh daemon in the background and does an initial
  gh auth login from GITHUB_TOKEN/GH_TOKEN so gh works before the
  first refresh fires.

Dropped from PR #1664: cosmetic em-dash -> ASCII hyphen rewrites
(charset-normalizer noise) that would conflict with the repo's
existing em-dash convention used elsewhere in workspace/.
This commit is contained in:
Hongming Wang 2026-04-22 19:52:46 -07:00
parent 32555a884a
commit 2885583d05
4 changed files with 244 additions and 25 deletions

View File

@ -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.

View File

@ -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 "$@"

View File

@ -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

View File

@ -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