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:
109
tests/test-anthropic-connection.mjs
Normal file
109
tests/test-anthropic-connection.mjs
Normal 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
80
tests/test-sync-flow.sh
Executable 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
66
tests/test-token-refresh.sh
Executable 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."
|
||||
Reference in New Issue
Block a user