Skip to content

Instantly share code, notes, and snippets.

@yarikoptic
Created May 12, 2026 13:01
Show Gist options
  • Select an option

  • Save yarikoptic/fcb97a2955094185dea40ab8f0d7f129 to your computer and use it in GitHub Desktop.

Select an option

Save yarikoptic/fcb97a2955094185dea40ab8f0d7f129 to your computer and use it in GitHub Desktop.
Workaround for anthropics/claude-code#1757: refresh Claude Code OAuth token via host cron before expiry
#!/bin/bash
# SPDX-FileCopyrightText: 2026 Yaroslav Halchenko <yaroslav.o.halchenko@dartmouth.edu>
# SPDX-License-Identifier: MIT
#
# Generated with Claude Code 2.1.132 / Claude Opus 4.7
#
# Refresh Claude Code OAuth token before natural expiry.
#
# Runs from host cron. Checks ~/.claude/.credentials.json and, if the access
# token is within 30 minutes of expiry, edits expiresAt to a past value and
# triggers a refresh by running `claude -p` in a minimal podman container.
# This sidesteps the "first claude instance after expiry needs /login" pattern
# caused by refresh-token rotation when long-running containers fall behind.
#
# Install: chmod +x ~/.claude/refresh-claude-if-expiring-soon.sh
# Cron: */15 * * * * /home/yoh/.claude/refresh-claude-if-expiring-soon.sh
#
# Manual test (host): /home/yoh/.claude/refresh-claude-if-expiring-soon.sh --dry-run
# Ref: https://github.com/anthropics/claude-code/issues/1757
set -u
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
CREDS="$CLAUDE_DIR/.credentials.json"
LOG="$CLAUDE_DIR/refresh-claude.log"
LOCK="$CLAUDE_DIR/.refresh-cron.lock"
IMAGE="con-bomination-claude-code"
# Refresh window: refresh if remaining time is in [-WINDOW_LATE_MS, WINDOW_EARLY_MS]
WINDOW_EARLY_MS=$((30 * 60 * 1000)) # 30 min before expiry
WINDOW_LATE_MS=$((5 * 60 * 1000)) # up to 5 min past expiry
DRY_RUN=0
[ "${1:-}" = "--dry-run" ] && DRY_RUN=1
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" >> "$LOG"; }
# Single-instance lock — skip silently if another run is in progress.
exec 9>"$LOCK" || { log "cannot open lock $LOCK"; exit 1; }
flock -n 9 || { log "another refresh in progress; skipping"; exit 0; }
[ -f "$CREDS" ] || { log "no credentials at $CREDS; skipping"; exit 0; }
NOW_MS=$(($(date +%s) * 1000))
EXP_MS=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CREDS" 2>/dev/null || echo 0)
REMAINING=$(( EXP_MS - NOW_MS ))
if (( REMAINING > WINDOW_EARLY_MS )); then
# Plenty of time. Quiet exit — we don't need to log every cron tick.
[ "$DRY_RUN" = 1 ] && echo "ok: REMAINING=$((REMAINING/1000))s; nothing to do"
exit 0
fi
if (( REMAINING < -WINDOW_LATE_MS )); then
# Expired well in the past. If we forced a refresh now, we'd race with whichever
# natural-failure path containers are already taking. Let them lose-then-recover
# (or prompt /login) — that's the existing behavior.
log "expired ${REMAINING}ms ago; outside window; skipping"
[ "$DRY_RUN" = 1 ] && echo "skip: REMAINING=$((REMAINING/1000))s (too late)"
exit 0
fi
log "refresh window hit: REMAINING=$((REMAINING/1000))s; forcing refresh"
if [ "$DRY_RUN" = 1 ]; then
echo "DRY RUN: would set expiresAt to past and run claude -p in $IMAGE"
exit 0
fi
# Atomic: write a sibling temp file, fsync-rename. Same pattern claude itself uses.
TMP="$CREDS.tmp.cron.$$"
PAST_EXP=$((NOW_MS - 60000))
if ! jq --argjson e "$PAST_EXP" '.claudeAiOauth.expiresAt = $e' "$CREDS" > "$TMP"; then
log " jq edit failed; aborting"
rm -f "$TMP"
exit 1
fi
chmod 600 "$TMP"
mv "$TMP" "$CREDS"
# Trigger the actual refresh via a minimal podman container.
# Note: NO -it (cron has no TTY). NO --dangerously-skip-permissions (we're not
# using any tools — just a one-token completion).
NAME="claude-refresh-$$"
timeout 90 podman run --rm --log-driver=none \
--userns=keep-id:uid=1000,gid=1000 \
--name "$NAME" \
-v "$CLAUDE_DIR:$CLAUDE_DIR:z" \
-e CLAUDE_CONFIG_DIR="$CLAUDE_DIR" \
-w /tmp \
"$IMAGE" \
claude -p --no-session-persistence "ok" \
>> "$LOG" 2>&1
RC=$?
NEW_EXP=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CREDS" 2>/dev/null || echo 0)
if (( NEW_EXP > EXP_MS )); then
log " success: new expiresAt=$NEW_EXP ($(date -d @$((NEW_EXP/1000)) '+%F %T %Z'))"
elif [ "$RC" -ne 0 ]; then
log " FAILED: claude -p exited $RC; expiresAt unchanged"
else
log " FAILED: claude -p exited 0 but expiresAt unchanged ($NEW_EXP); refresh path didn't fire"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment