#!/bin/bash # refresh-claude-token.sh — Directly refreshes Claude Max OAuth token via Anthropic API # Fallback method for servers without a Claude CLI container doing auto-refresh # Run via systemd timer every 6 hours # # Endpoint: POST https://console.anthropic.com/v1/oauth/token # Client ID: 9d1c250a-e61b-44d9-88ed-5944d1962f5e (Claude Code public OAuth client) set -euo pipefail # --- Configuration (can be overridden via env vars or setup.sh substitution) --- CLAUDE_CREDS_FILE="${CLAUDE_CREDS_FILE:-@@CLAUDE_CREDS_FILE@@}" OPENCLAW_OAUTH_FILE="${OPENCLAW_OAUTH_FILE:-@@OPENCLAW_OAUTH_FILE@@}" OPENCLAW_ENV_FILE="${OPENCLAW_ENV_FILE:-@@OPENCLAW_ENV_FILE@@}" COMPOSE_DIR="${COMPOSE_DIR:-@@COMPOSE_DIR@@}" TOKEN_URL="https://console.anthropic.com/v1/oauth/token" CLIENT_ID="9d1c250a-e61b-44d9-88ed-5944d1962f5e" LOG_PREFIX="[refresh-claude-token]" log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $LOG_PREFIX $*"; } error() { echo "$(date '+%Y-%m-%d %H:%M:%S') $LOG_PREFIX ERROR: $*" >&2; } # Check credentials file exists (source of current refresh token) if [ ! -f "$CLAUDE_CREDS_FILE" ]; then error "Credentials file not found: $CLAUDE_CREDS_FILE" exit 1 fi # Extract current refresh token REFRESH_TOKEN=$(python3 -c " import json, sys try: with open('$CLAUDE_CREDS_FILE') as f: data = json.load(f) print(data['claudeAiOauth']['refreshToken']) except Exception as e: print(f'ERROR: {e}', file=sys.stderr) sys.exit(1) ") if [ -z "$REFRESH_TOKEN" ]; then error "No refresh token found in $CLAUDE_CREDS_FILE" exit 1 fi log "Refreshing token..." # Call Anthropic's OAuth token endpoint RESPONSE=$(curl -s -X POST "$TOKEN_URL" \ -H "Content-Type: application/json" \ -d "{\"grant_type\":\"refresh_token\",\"refresh_token\":\"$REFRESH_TOKEN\",\"client_id\":\"$CLIENT_ID\"}") # Parse response and write to all credential locations python3 -c " import json, sys, time, os, re resp = json.loads('''$RESPONSE''') if 'access_token' not in resp: print('$LOG_PREFIX ERROR: Refresh failed:', json.dumps(resp), file=sys.stderr) sys.exit(1) access_token = resp['access_token'] refresh_token = resp['refresh_token'] expires_at = int(time.time() * 1000) + (resp['expires_in'] * 1000) scopes = resp.get('scope', 'user:inference user:mcp_servers user:profile user:sessions:claude_code').split(' ') # 1. Write Claude CLI credentials format (keeps refresh token for next run) claude_creds = { 'claudeAiOauth': { 'accessToken': access_token, 'refreshToken': refresh_token, 'expiresAt': expires_at, 'scopes': scopes, 'subscriptionType': 'max', 'rateLimitTier': 'default_claude_max_5x' } } with open('$CLAUDE_CREDS_FILE', 'w') as f: json.dump(claude_creds, f) print(f'Updated $CLAUDE_CREDS_FILE') # 2. Update ANTHROPIC_OAUTH_TOKEN in .env with open('$OPENCLAW_ENV_FILE') as f: env = f.read() if 'ANTHROPIC_OAUTH_TOKEN' in env: env = re.sub(r'ANTHROPIC_OAUTH_TOKEN=.*', f'ANTHROPIC_OAUTH_TOKEN=\"{access_token}\"', env) else: env = env.rstrip('\n') + f'\nANTHROPIC_OAUTH_TOKEN=\"{access_token}\"\n' with open('$OPENCLAW_ENV_FILE', 'w') as f: f.write(env) print(f'Updated $OPENCLAW_ENV_FILE') # 3. Write OpenClaw oauth.json (field-mapped) openclaw_oauth = { 'anthropic': { 'access': access_token, 'refresh': refresh_token, 'expires': expires_at, 'scopes': scopes, 'subscriptionType': 'max', 'rateLimitTier': 'default_claude_max_5x' } } os.makedirs(os.path.dirname('$OPENCLAW_OAUTH_FILE'), exist_ok=True) with open('$OPENCLAW_OAUTH_FILE', 'w') as f: json.dump(openclaw_oauth, f) print(f'Updated $OPENCLAW_OAUTH_FILE') expires_hours = resp['expires_in'] // 3600 account = resp.get('account', {}).get('email_address', 'unknown') print(f'OK: account={account}, expires in {expires_hours}h, token={access_token[:20]}...') " if [ $? -ne 0 ]; then error "Token refresh failed" exit 1 fi # 4. Recreate gateway container (down/up, NOT restart) cd "$COMPOSE_DIR" log "Recreating gateway container..." docker compose down openclaw-gateway 2>&1 | sed "s/^/$LOG_PREFIX /" docker compose up -d openclaw-gateway 2>&1 | sed "s/^/$LOG_PREFIX /" log "Gateway recreated with new token"