forked from molecule-ai/molecule-core
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:
parent
32555a884a
commit
2885583d05
@ -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.
|
||||
|
||||
@ -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 "$@"
|
||||
|
||||
54
workspace/scripts/molecule-gh-token-refresh.sh
Executable file
54
workspace/scripts/molecule-gh-token-refresh.sh
Executable 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
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user