#!/bin/bash # sync-oauth-token.sh — Watches Claude CLI credentials and syncs to OpenClaw # Runs as a long-lived systemd service # # When Claude Code CLI auto-refreshes its OAuth token, this script detects # the file change via inotifywait and syncs the fresh token to: # 1. OpenClaw's oauth.json (field-mapped) # 2. OpenClaw's .env (ANTHROPIC_OAUTH_TOKEN) # 3. Recreates the gateway container (down/up, NOT restart) set -uo pipefail # --- Configuration (substituted by setup.sh) --- CLAUDE_CREDS_FILE="@@CLAUDE_CREDS_FILE@@" OPENCLAW_OAUTH_FILE="@@OPENCLAW_OAUTH_FILE@@" OPENCLAW_ENV_FILE="@@OPENCLAW_ENV_FILE@@" COMPOSE_DIR="@@COMPOSE_DIR@@" LOG_PREFIX="[sync-oauth-token]" LAST_SYNC=0 DEBOUNCE_SECONDS=10 # --- Logging --- log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $LOG_PREFIX $*"; } error() { echo "$(date '+%Y-%m-%d %H:%M:%S') $LOG_PREFIX ERROR: $*" >&2; } sync_token() { # Debounce: skip if last sync was recent local now now=$(date +%s) local elapsed=$((now - LAST_SYNC)) if [ "$elapsed" -lt "$DEBOUNCE_SECONDS" ]; then log "Debounce: skipping (last sync ${elapsed}s ago)" return 0 fi log "Credential file changed, syncing..." if [ ! -f "$CLAUDE_CREDS_FILE" ]; then error "Source file not found: $CLAUDE_CREDS_FILE" return 1 fi # Extract and convert fields, write to both targets python3 -c " import json, sys, os, re, time with open('$CLAUDE_CREDS_FILE') as f: src = json.load(f) oauth = src.get('claudeAiOauth', {}) access = oauth.get('accessToken', '') refresh = oauth.get('refreshToken', '') expires = oauth.get('expiresAt', 0) scopes = oauth.get('scopes', []) sub_type = oauth.get('subscriptionType', 'max') rate_tier = oauth.get('rateLimitTier', 'default_claude_max_5x') if not access: print('$LOG_PREFIX ERROR: No access token in source', file=sys.stderr) sys.exit(1) # 1. Write OpenClaw oauth.json (field name mapping) # accessToken -> access # refreshToken -> refresh # expiresAt -> expires openclaw = { 'anthropic': { 'access': access, 'refresh': refresh, 'expires': expires, 'scopes': scopes, 'subscriptionType': sub_type, 'rateLimitTier': rate_tier } } os.makedirs(os.path.dirname('$OPENCLAW_OAUTH_FILE'), exist_ok=True) with open('$OPENCLAW_OAUTH_FILE', 'w') as f: json.dump(openclaw, f) print(f'Updated $OPENCLAW_OAUTH_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}\"', env) else: env = env.rstrip('\n') + f'\nANTHROPIC_OAUTH_TOKEN=\"{access}\"\n' with open('$OPENCLAW_ENV_FILE', 'w') as f: f.write(env) print(f'Updated $OPENCLAW_ENV_FILE') remaining = (expires / 1000 - time.time()) / 3600 print(f'Token: {access[:20]}... expires in {remaining:.1f}h') " if [ $? -ne 0 ]; then error "Failed to sync token" return 1 fi # 3. Recreate gateway container to load new env var # CRITICAL: Must use down/up, NOT restart — restart doesn't reload .env cd "$COMPOSE_DIR" log "Recreating gateway container..." docker compose down openclaw-gateway 2>&1 | while read -r line; do log " $line"; done docker compose up -d openclaw-gateway 2>&1 | while read -r line; do log " $line"; done log "Gateway recreated with new token" LAST_SYNC=$(date +%s) } # --- Main --- log "Starting file watcher on $CLAUDE_CREDS_FILE" # Verify source file exists if [ ! -f "$CLAUDE_CREDS_FILE" ]; then error "Source credentials file not found: $CLAUDE_CREDS_FILE" error "Make sure Claude Code CLI is installed and authenticated in the container" exit 1 fi # Initial sync on startup sync_token # Watch for modifications using inotifywait # Watch the DIRECTORY (not file) to handle atomic rename writes WATCH_DIR=$(dirname "$CLAUDE_CREDS_FILE") WATCH_FILE=$(basename "$CLAUDE_CREDS_FILE") log "Watching directory: $WATCH_DIR for changes to $WATCH_FILE" while inotifywait -q -e close_write,moved_to "$WATCH_DIR" 2>/dev/null; do # Small delay to ensure write is complete sleep 1 if [ -f "$CLAUDE_CREDS_FILE" ]; then sync_token fi done # If inotifywait exits, log and let systemd restart us error "inotifywait exited unexpectedly" exit 1