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:
shamid202
2026-02-27 01:51:18 +07:00
parent 3ae5d5274a
commit 22731fff60
24 changed files with 2846 additions and 6 deletions

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env node
// test-anthropic-connection.mjs — Test Anthropic API connectivity with OAuth token
// Usage: node test-anthropic-connection.mjs [token]
// If no token provided, reads from oauth.json or .env
import https from 'node:https';
import fs from 'node:fs';
// Find token
let token = process.argv[2];
if (!token) {
// Try oauth.json
for (const path of [
'/root/.openclaw/credentials/oauth.json',
'/home/node/.openclaw/credentials/oauth.json',
]) {
try {
const data = JSON.parse(fs.readFileSync(path, 'utf8'));
token = data.anthropic?.access;
if (token) { console.log(`Token from: ${path}`); break; }
} catch {}
}
}
if (!token) {
// Try .env
try {
const env = fs.readFileSync('/root/openclaw/.env', 'utf8');
const match = env.match(/ANTHROPIC_OAUTH_TOKEN="?([^"\n]+)/);
if (match) { token = match[1]; console.log('Token from: .env'); }
} catch {}
}
if (!token) {
console.error('ERROR: No token found. Provide as argument or ensure oauth.json/.env exists.');
process.exit(1);
}
console.log(`Token: ${token.substring(0, 20)}...`);
console.log('');
// Test API
const body = JSON.stringify({
model: 'claude-sonnet-4-20250514',
max_tokens: 20,
messages: [{ role: 'user', content: 'Say "hello" and nothing else.' }],
});
const isOAuth = token.startsWith('sk-ant-oat');
const headers = {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
'Content-Length': Buffer.byteLength(body),
};
if (isOAuth) {
headers['Authorization'] = `Bearer ${token}`;
// Claude Code identity headers required for OAuth
headers['anthropic-beta'] = 'claude-code-20250219,oauth-2025-04-20';
headers['user-agent'] = 'claude-cli/2.1.0';
headers['x-app'] = 'cli';
console.log('Auth: Bearer (OAuth token)');
} else {
headers['x-api-key'] = token;
console.log('Auth: x-api-key');
}
console.log('Sending test request to api.anthropic.com...');
console.log('');
const req = https.request({
hostname: 'api.anthropic.com',
path: '/v1/messages',
method: 'POST',
headers,
}, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => {
console.log(`Status: ${res.statusCode}`);
if (res.statusCode === 200) {
try {
const parsed = JSON.parse(data);
const text = parsed.content?.[0]?.text || '';
console.log(`Response: "${text}"`);
console.log(`Model: ${parsed.model}`);
console.log('');
console.log('SUCCESS: Anthropic API connection working');
} catch {
console.log('Response:', data.substring(0, 200));
}
} else {
console.log('Response:', data.substring(0, 500));
console.log('');
console.log('FAILED: API returned non-200 status');
process.exit(1);
}
});
});
req.on('error', (err) => {
console.error('Connection error:', err.message);
process.exit(1);
});
req.write(body);
req.end();

80
tests/test-sync-flow.sh Executable file
View File

@@ -0,0 +1,80 @@
#!/bin/bash
# test-sync-flow.sh — Test that file-watch -> sync -> gateway flow works
# Triggers a fake file change and verifies the sync happens
set -uo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'
pass() { echo -e "${GREEN}[PASS]${NC} $*"; }
fail() { echo -e "${RED}[FAIL]${NC} $*"; }
echo ""
echo "Testing OAuth Token Sync Flow"
echo "=============================="
echo ""
# Find the source credentials file
SYNC_SCRIPT=$(which sync-oauth-token.sh 2>/dev/null || echo "/usr/local/bin/sync-oauth-token.sh")
if [ ! -f "$SYNC_SCRIPT" ]; then
fail "sync-oauth-token.sh not found at $SYNC_SCRIPT"
exit 1
fi
SOURCE_FILE=$(grep 'CLAUDE_CREDS_FILE=' "$SYNC_SCRIPT" | head -1 | cut -d'"' -f2)
OAUTH_FILE=$(grep 'OPENCLAW_OAUTH_FILE=' "$SYNC_SCRIPT" | head -1 | cut -d'"' -f2)
if [ ! -f "$SOURCE_FILE" ]; then
fail "Source file not found: $SOURCE_FILE"
exit 1
fi
echo "Source: $SOURCE_FILE"
echo "Target: $OAUTH_FILE"
echo ""
# Record current oauth.json modification time
BEFORE_MTIME="none"
if [ -f "$OAUTH_FILE" ]; then
BEFORE_MTIME=$(stat -c %Y "$OAUTH_FILE" 2>/dev/null || stat -f %m "$OAUTH_FILE" 2>/dev/null)
fi
echo "1. Triggering file change (touch)..."
touch "$SOURCE_FILE"
echo "2. Waiting 15 seconds for sync to complete..."
sleep 15
# Check if oauth.json was updated
if [ -f "$OAUTH_FILE" ]; then
AFTER_MTIME=$(stat -c %Y "$OAUTH_FILE" 2>/dev/null || stat -f %m "$OAUTH_FILE" 2>/dev/null)
if [ "$AFTER_MTIME" != "$BEFORE_MTIME" ]; then
pass "oauth.json was updated (mtime changed)"
else
fail "oauth.json was NOT updated (mtime unchanged)"
fi
# Verify format
HAS_ACCESS=$(python3 -c "
import json
with open('$OAUTH_FILE') as f:
d = json.load(f)
print('yes' if d.get('anthropic', {}).get('access') else 'no')
" 2>/dev/null || echo "no")
if [ "$HAS_ACCESS" = "yes" ]; then
pass "oauth.json has correct format (anthropic.access present)"
else
fail "oauth.json has wrong format (missing anthropic.access)"
fi
else
fail "oauth.json does not exist after sync"
fi
echo ""
echo "3. Checking service logs..."
journalctl -u sync-oauth-token.service --since "1 minute ago" --no-pager -n 10 2>/dev/null || echo " (journalctl not available)"
echo ""
echo "Done."

66
tests/test-token-refresh.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/bin/bash
# test-token-refresh.sh — Test if the current refresh token can get a new access token
# Does NOT write any files — read-only test
set -euo pipefail
TOKEN_URL="https://console.anthropic.com/v1/oauth/token"
CLIENT_ID="9d1c250a-e61b-44d9-88ed-5944d1962f5e"
# Find credentials file
CREDS_FILE="${1:-}"
if [ -z "$CREDS_FILE" ]; then
for path in \
/root/.openclaw/workspaces/workspace-claude-proxy/config/.claude/.credentials.json \
/root/.claude/.credentials.json \
"$HOME/.claude/.credentials.json"; do
if [ -f "$path" ]; then
CREDS_FILE="$path"
break
fi
done
fi
if [ -z "$CREDS_FILE" ] || [ ! -f "$CREDS_FILE" ]; then
echo "ERROR: No credentials file found. Provide path as argument."
exit 1
fi
echo "Credentials file: $CREDS_FILE"
# Extract refresh token
REFRESH_TOKEN=$(python3 -c "
import json
with open('$CREDS_FILE') as f:
d = json.load(f)
print(d['claudeAiOauth']['refreshToken'])
")
echo "Refresh token: ${REFRESH_TOKEN:0:20}..."
echo ""
echo "Testing refresh 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\"}")
python3 -c "
import json, sys
resp = json.loads('''$RESPONSE''')
if 'access_token' in resp:
print('SUCCESS')
print(f' New access token: {resp[\"access_token\"][:20]}...')
print(f' Expires in: {resp[\"expires_in\"] // 3600}h')
account = resp.get('account', {}).get('email_address', 'unknown')
print(f' Account: {account}')
else:
print('FAILED')
print(f' Error: {resp.get(\"error\", \"unknown\")}')
print(f' Description: {resp.get(\"error_description\", \"unknown\")}')
sys.exit(1)
"
echo ""
echo "Note: This was a read-only test. No files were modified."