Created
May 12, 2026 13:01
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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