Add complete OAuth token refresh and sync solution
- Setup wizard with auto-detection of OpenClaw paths and Claude CLI - Token sync watcher (inotifywait) for real-time credential updates - Auto-refresh trigger timer that runs Claude CLI every 30 min - Supports Claude CLI in Docker container or on host - Temporary ANTHROPIC_BASE_URL override for container environments - Anthropic model configuration for OpenClaw - Auth profile management (fixes key vs access field) - Systemd services and timers for both sync and trigger - Comprehensive documentation and troubleshooting guides - Re-authentication notification system Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
142
scripts/sync-oauth-token.sh
Executable file
142
scripts/sync-oauth-token.sh
Executable file
@@ -0,0 +1,142 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user