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

20
LICENSE
View File

@ -1,9 +1,21 @@
MIT License MIT License
Copyright (c) 2026 ROOH Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

263
README.md
View File

@ -1,3 +1,262 @@
# openclaw_oauth_sync # OAuth Fix for OpenClaw + Claude Max
Automatic Anthropic OAuth token refresh and sync for OpenClaw. Keeps your Claude Max tokens alive indefinitely. Automatic Anthropic OAuth token refresh for OpenClaw. Keeps your Claude Max tokens alive indefinitely.
## The Problem
OpenClaw uses Anthropic Claude models (e.g., `claude-opus-4-6`) via OAuth tokens from Claude Max subscriptions. These tokens **expire every ~8 hours**. Without automated refresh, all agents stop working with:
```
HTTP 401 authentication_error: OAuth token has expired
```
## The Solution
Two services working together:
1. **Auto-refresh trigger** — A timer that runs Claude CLI every 30 minutes to check and refresh the token before it expires. Supports Claude CLI in a Docker container or installed on the host.
2. **Token sync watcher** — An `inotifywait` service that detects when Claude CLI writes new credentials and instantly syncs them to OpenClaw.
```
Timer (every 30min) Claude Code CLI sync-oauth-token.sh OpenClaw Gateway
(triggers CLI when ---> (refreshes token, ---> (watches for changes, ---> (gets fresh token)
token near expiry) writes creds file) syncs via inotifywait)
```
## Quick Start
```bash
git clone https://github.com/YOUR_USER/oauth-fix-openclaw-final.git
cd oauth-fix-openclaw-final
sudo ./setup.sh
```
The interactive wizard will:
1. Detect your OpenClaw installation paths
2. Find Claude CLI credentials
3. Configure the Anthropic model (if not already set up)
4. Install the token sync watcher (inotifywait)
5. Detect Claude CLI (container or host) and install the auto-refresh trigger
6. Test the CLI invocation to confirm it works
7. Verify everything works
## Prerequisites
- Linux server with **systemd**
- **Docker** + **Docker Compose v2**
- **OpenClaw** installed and running
- **Claude Max subscription** with OAuth credentials
- **Claude Code CLI** — either in a Docker container or installed on the host
- **python3**
- **inotify-tools** (optional, installed by wizard if missing)
## How It Works
### Architecture
```
+---------------------------+
| trigger-claude-refresh.sh | (systemd timer, every 30 min)
| checks token expiry |
| if < 1.5h remaining: |
| triggers Claude CLI |
+-------------+-------------+
|
v
+--------------------+ auto-refresh +-------------------+
| Claude Code CLI | =================> | .credentials.json |
| (container or host)| (token near expiry) +--------+----------+
+--------------------+ |
inotifywait detects change
|
+----------v----------+
| sync-oauth-token.sh |
+--+------+------+----+
| | |
oauth.json .env gateway
(mapped (env restart
fields) var) (down/up)
```
### Token Flow
1. `trigger-claude-refresh.sh` runs every 30 minutes, checks token expiry
2. If token has < 1.5 hours remaining, triggers Claude CLI
3. Claude CLI detects its token is near expiry
4. CLI calls Anthropic's refresh endpoint, gets new access token
5. CLI writes updated `.credentials.json`
6. `inotifywait` detects the file change (< 1 second)
7. `sync-oauth-token.sh` reads the new token
8. Maps fields: `accessToken` -> `access`, `refreshToken` -> `refresh`, `expiresAt` -> `expires`
9. Writes to `oauth.json` (OpenClaw's format)
10. Updates `ANTHROPIC_OAUTH_TOKEN` in `.env`
11. Recreates gateway container (`docker compose down/up` — NOT restart!)
12. Gateway starts with the fresh token
### ANTHROPIC_BASE_URL Override
If Claude CLI runs in a container with `ANTHROPIC_BASE_URL` set to a proxy (e.g., LiteLLM), the trigger script uses a **temporary per-invocation override**:
```bash
docker exec -e ANTHROPIC_BASE_URL=https://api.anthropic.com container claude -p "say ok"
```
The `-e` flag overrides the env var only for that single command. The container's running processes are unaffected. This is detected and configured automatically by the wizard.
### Re-authentication Notification
If the refresh token itself expires (e.g., subscription lapsed), the trigger script:
- Creates a flag file at `REAUTH_NEEDED` in the OpenClaw directory
- Logs an error to journalctl
- Future: Mattermost webhook notification
### Why down/up and NOT restart?
`docker compose restart` does **NOT** reload `.env` variables. It restarts the same container with the same environment. Only `docker compose down` + `docker compose up -d` creates a new container that reads `.env` fresh.
## Anthropic Model Configuration
OpenClaw has a **built-in** Anthropic provider. **Do NOT** add `anthropic` to `models.providers` in `openclaw.json` — it causes double `/v1` in URLs resulting in 404 errors.
The correct configuration (set by the wizard):
```json
{
"agents": {
"defaults": {
"model": {
"primary": "anthropic/claude-opus-4-6"
},
"models": {
"anthropic/claude-opus-4-6": { "alias": "Claude Opus 4.6 (Max)" },
"anthropic/claude-sonnet-4-6": { "alias": "Claude Sonnet 4.6 (Max)" }
}
}
}
}
```
See [docs/OPENCLAW-MODEL-CONFIG.md](docs/OPENCLAW-MODEL-CONFIG.md) for full details.
## Credential Field Mapping
Claude CLI and OpenClaw use different field names:
| Claude CLI (`.credentials.json`) | OpenClaw (`oauth.json`) |
|----------------------------------|------------------------|
| `claudeAiOauth.accessToken` | `anthropic.access` |
| `claudeAiOauth.refreshToken` | `anthropic.refresh` |
| `claudeAiOauth.expiresAt` | `anthropic.expires` |
See [docs/FIELD-MAPPING.md](docs/FIELD-MAPPING.md) for all formats.
## Manual Installation
If you prefer not to use the wizard:
### 1. Install inotify-tools
```bash
apt install inotify-tools
```
### 2. Edit and install the sync script
```bash
# Edit scripts/sync-oauth-token.sh — replace @@PLACEHOLDER@@ values:
# @@CLAUDE_CREDS_FILE@@ = path to Claude CLI .credentials.json
# @@OPENCLAW_OAUTH_FILE@@ = path to OpenClaw oauth.json
# @@OPENCLAW_ENV_FILE@@ = path to OpenClaw .env
# @@COMPOSE_DIR@@ = path to OpenClaw docker-compose directory
cp scripts/sync-oauth-token.sh /usr/local/bin/
chmod +x /usr/local/bin/sync-oauth-token.sh
```
### 3. Install systemd service
```bash
# Edit templates/sync-oauth-token.service — replace @@SYNC_SCRIPT_PATH@@
cp templates/sync-oauth-token.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now sync-oauth-token.service
```
### 4. Install the auto-refresh trigger
```bash
# Edit scripts/trigger-claude-refresh.sh — replace @@PLACEHOLDER@@ values:
# @@CREDS_FILE@@ = path to Claude CLI .credentials.json
# @@REAUTH_FLAG@@ = path to REAUTH_NEEDED flag file
# @@CLI_MODE@@ = "container" or "host"
# @@CLI_CONTAINER@@ = container name (if container mode)
# @@CLI_BASE_URL_OVERRIDE@@ = "true" or "false"
cp scripts/trigger-claude-refresh.sh /usr/local/bin/
chmod +x /usr/local/bin/trigger-claude-refresh.sh
# Edit templates/trigger-claude-refresh.service — replace @@TRIGGER_SCRIPT_PATH@@
cp templates/trigger-claude-refresh.service /etc/systemd/system/
cp templates/trigger-claude-refresh.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now trigger-claude-refresh.timer
```
### 5. Configure OpenClaw
See [docs/OPENCLAW-MODEL-CONFIG.md](docs/OPENCLAW-MODEL-CONFIG.md).
## Verification
```bash
# Run the health check
./scripts/verify.sh
# Watch sync logs in real-time
journalctl -u sync-oauth-token.service -f
# Check trigger logs
journalctl -u trigger-claude-refresh -n 20
# Check all timers
systemctl list-timers sync-oauth-token* trigger-claude-refresh*
# Check service status
systemctl status sync-oauth-token.service
systemctl status trigger-claude-refresh.timer
```
## Uninstall
```bash
./setup.sh --uninstall
# or
./scripts/uninstall.sh
```
## Fallback Method (Timer)
If `inotifywait` is unavailable, the wizard installs a systemd timer that refreshes the token directly via Anthropic's API every 6 hours. This is less responsive but doesn't require inotify.
## Troubleshooting
See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues:
- Token expired errors
- `docker compose restart` not reloading env
- Auth profile `key` vs `access` field
- 404 from custom anthropic provider
- Cooldown errors
- Claude CLI not responding (ANTHROPIC_BASE_URL override)
- REAUTH_NEEDED flag (refresh token expired)
## Documentation
- [Architecture](docs/ARCHITECTURE.md) — Token flow, volume mounts, auth resolution
- [Troubleshooting](docs/TROUBLESHOOTING.md) — Common issues and fixes
- [Model Config](docs/OPENCLAW-MODEL-CONFIG.md) — Anthropic model setup in OpenClaw
- [Token Refresh](docs/HOW-TOKEN-REFRESH-WORKS.md) — How Claude CLI refreshes tokens
- [Field Mapping](docs/FIELD-MAPPING.md) — Credential format reference
## License
MIT

View File

@ -0,0 +1,32 @@
{
"_comment": "Anthropic model configuration for OpenClaw. Merge into openclaw.json via setup wizard.",
"_warning": "Do NOT add 'anthropic' to models.providers — the built-in provider handles it. Adding a custom one causes double /v1 in URLs resulting in 404 errors.",
"agents_defaults_model": {
"primary": "anthropic/claude-opus-4-6",
"fallbacks_to_add": [
"anthropic/claude-sonnet-4-6"
]
},
"agents_defaults_models": {
"anthropic/claude-opus-4-6": {
"alias": "Claude Opus 4.6 (Max)"
},
"anthropic/claude-sonnet-4-6": {
"alias": "Claude Sonnet 4.6 (Max)"
}
},
"auth_profile": {
"anthropic:default": {
"type": "oauth",
"provider": "anthropic",
"access": "@@TOKEN@@"
}
},
"env_vars": {
"ANTHROPIC_OAUTH_TOKEN": "@@TOKEN@@"
}
}

118
docs/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,118 @@
# Architecture
## Token Flow Diagram
```
+--------------------+ auto-refresh +------------------------------------------+
| Claude Code CLI | ================> | .credentials.json |
| (inside | (every ~8 hours, | { |
| claude-proxy | built-in to CLI) | "claudeAiOauth": { |
| container) | | "accessToken": "sk-ant-oat01-...", |
+--------------------+ | "refreshToken": "sk-ant-ort01-...", |
| "expiresAt": 1772120060006 |
| } |
| } |
+-------------------+-----------------------+
|
inotifywait detects
CLOSE_WRITE / MOVED_TO
|
+-------------------v-----------------------+
| sync-oauth-token.sh |
| (systemd service, runs continuously) |
+---+----------------+----------------+-----+
| | |
+----------------+ +-----------+---------+ |
| | | |
+---------v---------+ +--------v--------+ +--------v--------+
| oauth.json | | .env | | docker compose |
| { | | ANTHROPIC_ | | down/up gateway |
| "anthropic": { | | OAUTH_TOKEN= | | (reloads env) |
| "access":..., | | "sk-ant-oat01-" | +---------+--------+
| "refresh":...,| +-----------------+ |
| "expires":... | +----------v----------+
| } | | OpenClaw Gateway |
| } | | (fresh token loaded |
+--------+----------+ | from container env) |
| +----------+----------+
| mergeOAuthFileIntoStore() |
| (reads on startup) |
+-------------------->+ |
| +--------v---------+
+------------->| api.anthropic.com|
| Claude Opus 4.6 |
+------------------+
```
## Volume Mounts (Docker)
```
HOST PATH CONTAINER PATH
========= ==============
Gateway container (openclaw-openclaw-gateway-1):
/root/.openclaw/ -> /home/node/.openclaw/
/root/.openclaw/credentials/oauth.json -> /home/node/.openclaw/credentials/oauth.json
/root/.openclaw/agents/*/agent/auth-profiles.json -> /home/node/.openclaw/agents/*/agent/auth-profiles.json
/home/node/.claude/ -> /home/node/.claude/
/root/openclaw/.env -> loaded as container env vars (at creation time only)
Claude CLI container (claude-proxy):
/root/.openclaw/workspaces/workspace-claude-proxy/
config/ -> /root/
config/.claude/.credentials.json -> /root/.claude/.credentials.json
```
## Auth Resolution Order (inside gateway)
When the gateway needs to authenticate with Anthropic:
```
1. resolveApiKeyForProvider("anthropic")
2. -> resolveAuthProfileOrder()
3. -> reads agents/<agent>/agent/auth-profiles.json
4. -> isValidProfile() checks each profile:
5. - type:"api_key" -> requires cred.key
6. - type:"oauth" -> requires cred.access (NOT cred.key!)
7. - type:"token" -> requires cred.token
8. -> If valid profile found: use it
9. -> If no valid profile: resolveEnvApiKey("anthropic")
10. -> Reads ANTHROPIC_OAUTH_TOKEN from container env
11. -> isOAuthToken(key) detects "sk-ant-oat" prefix
12. -> Uses Bearer auth + Claude Code identity headers
13. -> Sends request to api.anthropic.com
On gateway startup:
mergeOAuthFileIntoStore()
-> Reads /home/node/.openclaw/credentials/oauth.json
-> Merges into auth profile store (if profile doesn't exist)
```
## Why down/up and NOT restart
```
docker compose restart openclaw-gateway
-> Sends SIGTERM to container process
-> Restarts the SAME container (same env vars from creation time)
-> .env changes are NOT reloaded
-> Result: gateway still has OLD token
docker compose down openclaw-gateway && docker compose up -d openclaw-gateway
-> Stops and REMOVES the container
-> Creates a NEW container (reads .env fresh)
-> New env vars are loaded
-> Result: gateway has NEW token
```
## Source Code References (inside gateway container)
| File | Line | Function |
|------|------|----------|
| `/app/dist/paths-CyR9Pa1R.js` | 190 | `OAUTH_FILENAME = "oauth.json"` |
| `/app/dist/paths-CyR9Pa1R.js` | 198-204 | `resolveOAuthDir()` -> `$STATE_DIR/credentials/` |
| `/app/dist/paths-CyR9Pa1R.js` | 203 | `resolveOAuthPath()` -> joins dir + filename |
| `/app/dist/model-auth-CmUeBbp-.js` | 3048 | `mergeOAuthFileIntoStore()` -- reads oauth.json |
| `/app/dist/model-auth-CmUeBbp-.js` | 3358 | `buildOAuthApiKey()` -- returns `credentials.access` |
| `/app/dist/model-auth-CmUeBbp-.js` | 3832 | `isValidProfile()` -- for oauth, checks `cred.access` |
| `/app/dist/model-auth-CmUeBbp-.js` | 3942 | `resolveApiKeyForProvider()` -- profiles then env fallback |
| `/app/dist/model-auth-CmUeBbp-.js` | 4023 | `resolveEnvApiKey("anthropic")` -> reads env var |

93
docs/FIELD-MAPPING.md Normal file
View File

@ -0,0 +1,93 @@
# Credential Field Mapping Reference
## Claude CLI format (`.credentials.json`)
Written by Claude Code CLI when it refreshes the token.
```json
{
"claudeAiOauth": {
"accessToken": "sk-ant-oat01-...",
"refreshToken": "sk-ant-ort01-...",
"expiresAt": 1772120060006,
"scopes": ["user:inference", "user:mcp_servers", "user:profile", "user:sessions:claude_code"],
"subscriptionType": "max",
"rateLimitTier": "default_claude_max_5x"
}
}
```
## OpenClaw format (`oauth.json`)
Read by the gateway's `mergeOAuthFileIntoStore()` on startup.
```json
{
"anthropic": {
"access": "sk-ant-oat01-...",
"refresh": "sk-ant-ort01-...",
"expires": 1772120060006,
"scopes": ["user:inference", "user:mcp_servers", "user:profile", "user:sessions:claude_code"],
"subscriptionType": "max",
"rateLimitTier": "default_claude_max_5x"
}
}
```
## Field name mapping
| Claude CLI | OpenClaw | Notes |
|------------|----------|-------|
| `accessToken` | `access` | The OAuth access token (`sk-ant-oat01-...`) |
| `refreshToken` | `refresh` | The refresh token (`sk-ant-ort01-...`) |
| `expiresAt` | `expires` | Unix timestamp in milliseconds |
| `scopes` | `scopes` | Same format (array of strings) |
| `subscriptionType` | `subscriptionType` | Same (`"max"`) |
| `rateLimitTier` | `rateLimitTier` | Same (`"default_claude_max_5x"`) |
## .env format
Single env var, only the access token (no refresh/expiry):
```
ANTHROPIC_OAUTH_TOKEN="sk-ant-oat01-..."
```
## Auth profiles format (CORRECT)
```json
{
"profiles": {
"anthropic:default": {
"type": "oauth",
"provider": "anthropic",
"access": "sk-ant-oat01-..."
}
}
}
```
## Auth profiles format (BROKEN)
```json
{
"profiles": {
"anthropic:default": {
"type": "oauth",
"provider": "anthropic",
"key": "sk-ant-oat01-..."
}
}
}
```
**Why it's broken:** `isValidProfile()` for `type: "oauth"` checks `cred.access`, not `cred.key`. The profile is silently skipped, and auth falls through to the `ANTHROPIC_OAUTH_TOKEN` env var. This works by accident but means the auth profile system isn't being used properly.
## File locations
| File | Host Path | Container Path |
|------|-----------|---------------|
| Claude CLI creds | `/root/.openclaw/workspaces/workspace-claude-proxy/config/.claude/.credentials.json` | `/root/.claude/.credentials.json` (claude-proxy) |
| OpenClaw oauth | `/root/.openclaw/credentials/oauth.json` | `/home/node/.openclaw/credentials/oauth.json` (gateway) |
| .env | `/root/openclaw/.env` | loaded as env vars at container creation |
| Auth profiles | `/root/.openclaw/agents/<agent>/agent/auth-profiles.json` | `/home/node/.openclaw/agents/<agent>/agent/auth-profiles.json` (gateway) |

View File

@ -0,0 +1,107 @@
# How Token Refresh Works
## Anthropic OAuth Token Lifecycle
Claude Max subscriptions use OAuth tokens for API authentication.
- **Access token** (`sk-ant-oat01-...`): Used for API requests, expires in ~8 hours
- **Refresh token** (`sk-ant-ort01-...`): Used to get new access tokens, long-lived
- **Token endpoint**: `POST https://console.anthropic.com/v1/oauth/token`
- **Client ID**: `9d1c250a-e61b-44d9-88ed-5944d1962f5e` (Claude Code public OAuth client)
## How Claude Code CLI Refreshes Tokens
Claude Code CLI has built-in token refresh. Before each API request:
1. Checks `[API:auth] OAuth token check` — reads `.credentials.json`
2. If access token is expired or near expiry:
- Sends `grant_type: "refresh_token"` to Anthropic's token endpoint
- Gets back new `access_token`, `refresh_token`, `expires_in`
- Writes updated credentials back to `.credentials.json`
3. Uses the (possibly refreshed) access token for the API request
The relevant function in Claude Code's minified source (`cli.js`):
```javascript
// Simplified from minified source
async function refreshToken(refreshToken, scopes) {
const params = {
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
scope: scopes.join(" ")
};
const response = await axios.post(TOKEN_URL, params, {
headers: { "Content-Type": "application/json" }
});
return {
accessToken: response.data.access_token,
refreshToken: response.data.refresh_token || refreshToken,
expiresAt: Date.now() + response.data.expires_in * 1000,
scopes: response.data.scope.split(" ")
};
}
```
## Why We Need a Sync Service
The problem is that Claude Code CLI and OpenClaw are **separate processes in separate containers**:
- Claude Code CLI runs in `claude-proxy` container, writes to its own `.credentials.json`
- OpenClaw gateway runs in `openclaw-openclaw-gateway-1`, reads from `oauth.json` and env vars
They don't share the same credential files. So when Claude CLI refreshes the token, OpenClaw doesn't know about it.
## The Sync Approach (inotifywait)
We use `inotifywait` from `inotify-tools` to watch for file changes in real-time:
```bash
inotifywait -q -e close_write,moved_to "$WATCH_DIR"
```
- **`close_write`**: Fires when a file is written and closed (normal writes)
- **`moved_to`**: Fires when a file is moved into the directory (atomic writes: write to temp, then `mv`)
- We watch the **directory** not the file, because atomic renames create a new inode
When the file changes:
1. Read `accessToken`, `refreshToken`, `expiresAt` from Claude CLI format
2. Map fields: `accessToken` -> `access`, `refreshToken` -> `refresh`, `expiresAt` -> `expires`
3. Write to `oauth.json` (for gateway's `mergeOAuthFileIntoStore()`)
4. Update `ANTHROPIC_OAUTH_TOKEN` in `.env`
5. Recreate gateway container (`docker compose down/up`) to reload env vars
## The Fallback Approach (Timer)
If `inotifywait` is unavailable, a systemd timer runs every 6 hours:
1. Reads current refresh token from `.credentials.json`
2. Calls Anthropic's token endpoint directly
3. Writes new tokens to all credential locations
4. Recreates gateway container
This is less responsive (up to 6-hour delay) but works without inotify.
## Field Mapping
| Claude CLI (`.credentials.json`) | OpenClaw (`oauth.json`) |
|----------------------------------|------------------------|
| `claudeAiOauth.accessToken` | `anthropic.access` |
| `claudeAiOauth.refreshToken` | `anthropic.refresh` |
| `claudeAiOauth.expiresAt` | `anthropic.expires` |
| `claudeAiOauth.scopes` | `anthropic.scopes` |
## Timeline
```
T=0h Token issued (access + refresh)
T=7.5h Claude CLI detects token nearing expiry
T=7.5h CLI calls refresh endpoint, gets new tokens
T=7.5h CLI writes new .credentials.json
T=7.5h inotifywait detects change (< 1 second)
T=7.5h sync-oauth-token.sh syncs to oauth.json + .env
T=7.5h Gateway recreated with fresh token
T=8h Old token would have expired (but we already refreshed)
```
The entire sync happens within seconds of the CLI refresh, well before the old token expires.

View File

@ -0,0 +1,140 @@
# Configuring Anthropic Models in OpenClaw
## CRITICAL: Do NOT add an "anthropic" provider to models.providers
OpenClaw has a **built-in** Anthropic provider. You do NOT need to (and must NOT) add a custom `anthropic` entry to `models.providers` in `openclaw.json`.
Adding one causes the Anthropic SDK to append `/v1` to your `baseUrl`, which already has `/v1`, resulting in:
```
https://api.anthropic.com/v1/v1/messages -> 404 Not Found
```
## Correct Configuration
### 1. Set the primary model
In `openclaw.json`, under `agents.defaults.model`:
```json
{
"agents": {
"defaults": {
"model": {
"primary": "anthropic/claude-opus-4-6",
"fallbacks": [
"anthropic/claude-sonnet-4-6",
"google/gemini-3.1-pro-preview"
]
}
}
}
}
```
The `anthropic/` prefix tells OpenClaw to use the built-in Anthropic provider. No extra configuration needed.
### 2. Add model aliases (optional)
Under `agents.defaults.models`:
```json
{
"agents": {
"defaults": {
"models": {
"anthropic/claude-opus-4-6": {
"alias": "Claude Opus 4.6 (Max)"
},
"anthropic/claude-sonnet-4-6": {
"alias": "Claude Sonnet 4.6 (Max)"
}
}
}
}
}
```
### 3. Set ANTHROPIC_OAUTH_TOKEN in .env
In your OpenClaw `.env` file (e.g., `/root/openclaw/.env`):
```
ANTHROPIC_OAUTH_TOKEN="sk-ant-oat01-YOUR_TOKEN_HERE"
```
This is the fallback auth method. The gateway reads it as a container environment variable.
### 4. Create auth profiles for agents
Each agent needs an `anthropic:default` profile in its `auth-profiles.json`:
```json
{
"profiles": {
"anthropic:default": {
"type": "oauth",
"provider": "anthropic",
"access": "sk-ant-oat01-YOUR_TOKEN_HERE"
}
},
"lastGood": {
"anthropic": "anthropic:default"
}
}
```
**Important:** The field must be `access`, NOT `key`. Using `key` with `type: "oauth"` causes the profile to be silently skipped.
### 5. Create oauth.json
At `/root/.openclaw/credentials/oauth.json` (maps to `/home/node/.openclaw/credentials/oauth.json` in the gateway container):
```json
{
"anthropic": {
"access": "sk-ant-oat01-YOUR_TOKEN_HERE",
"refresh": "sk-ant-ort01-YOUR_REFRESH_TOKEN",
"expires": 1772120060006,
"scopes": ["user:inference", "user:mcp_servers", "user:profile", "user:sessions:claude_code"],
"subscriptionType": "max",
"rateLimitTier": "default_claude_max_5x"
}
}
```
## Available Built-in Models
When using the built-in Anthropic provider:
- `anthropic/claude-opus-4-6`
- `anthropic/claude-sonnet-4-6`
- Other models listed in the Anthropic API
## Per-Agent Model Override
You can set a specific model per agent:
```json
{
"agents": {
"list": [
{
"id": "my-agent",
"model": "anthropic/claude-opus-4-6"
}
]
}
}
```
## Authentication Flow
1. Gateway checks `auth-profiles.json` for a valid `anthropic:default` profile
2. For `type: "oauth"`, it requires the `access` field (not `key`)
3. If no valid profile: falls back to `ANTHROPIC_OAUTH_TOKEN` env var
4. On startup, `mergeOAuthFileIntoStore()` reads `oauth.json` and merges credentials
5. `isOAuthToken()` detects the `sk-ant-oat` prefix
6. Uses Bearer auth + Claude Code identity headers to call `api.anthropic.com`
## OAuth Token Lifecycle
Tokens from Claude Max subscriptions expire every ~8 hours. Use the sync service from this project to keep them fresh automatically. See the main README for setup instructions.

154
docs/TROUBLESHOOTING.md Normal file
View File

@ -0,0 +1,154 @@
# Troubleshooting
## "HTTP 401 authentication_error: OAuth token has expired"
The most common error. The OAuth token has a ~8 hour lifetime.
**Check:**
1. Is the sync service running? `systemctl status sync-oauth-token.service`
2. Is inotifywait watching? `pgrep -af inotifywait`
3. Is the source credentials file being updated? `stat /root/.openclaw/workspaces/workspace-claude-proxy/config/.claude/.credentials.json`
4. Check service logs: `journalctl -u sync-oauth-token.service -f`
**Fix:**
- If service stopped: `systemctl restart sync-oauth-token.service`
- If token expired everywhere: run `./scripts/refresh-claude-token.sh` manually
- Nuclear option: `claude login` inside the Claude CLI container, then restart sync service
---
## "docker compose restart doesn't reload .env"
This is a Docker Compose design behavior, not a bug.
`docker compose restart` only sends SIGTERM and restarts the container process. The container keeps its original environment variables from creation time.
**Always use:**
```bash
cd /root/openclaw
docker compose down openclaw-gateway
docker compose up -d openclaw-gateway
```
This destroys the container and creates a new one, reading `.env` fresh.
---
## Auth profile has "key" field instead of "access"
OpenClaw's `isValidProfile()` for `type: "oauth"` checks for `cred.access`, not `cred.key`. If your auth profile looks like:
```json
{
"anthropic:default": {
"type": "oauth",
"provider": "anthropic",
"key": "sk-ant-oat01-..." <-- WRONG
}
}
```
The profile is silently skipped and falls through to the env var.
**Fix:** Run `./scripts/fix-auth-profiles.sh`
The correct format is:
```json
{
"anthropic:default": {
"type": "oauth",
"provider": "anthropic",
"access": "sk-ant-oat01-..." <-- CORRECT
}
}
```
---
## "404 model_not_found" or double /v1 in URL
This happens when you add `anthropic` to `models.providers` in `openclaw.json`.
**Do NOT do this:**
```json
"models": {
"providers": {
"anthropic": {
"baseUrl": "https://api.anthropic.com/v1", <-- WRONG
...
}
}
}
```
The built-in Anthropic provider already handles routing. Adding a custom one with `baseUrl` ending in `/v1` causes the SDK to append another `/v1`, resulting in `https://api.anthropic.com/v1/v1/messages` -> 404.
**Fix:** Remove any `anthropic` entry from `models.providers`. The built-in provider handles it automatically when you reference `anthropic/claude-opus-4-6` as the model.
---
## "No available auth profile for anthropic (all in cooldown)"
Auth profiles enter a cooldown period after repeated failures (e.g., expired tokens, wrong model names).
**Fix:**
```bash
./scripts/fix-auth-profiles.sh
```
This clears `cooldownUntil`, `errorCount`, and `failureCounts` from all agent auth profiles.
---
## inotifywait: "No such file or directory"
The watched file or directory doesn't exist yet.
**Check:**
- Does the Claude CLI container exist? `docker ps | grep claude`
- Does the credentials path exist? `ls -la /root/.openclaw/workspaces/workspace-claude-proxy/config/.claude/`
- Has Claude CLI been authenticated? You may need to run `claude login` inside the container first.
---
## Gateway starts but Anthropic model still fails
After recreating the gateway, wait a few seconds for it to fully start. Then verify:
```bash
# Check container has the new token
docker exec openclaw-openclaw-gateway-1 printenv ANTHROPIC_OAUTH_TOKEN
# Check oauth.json was picked up
docker exec openclaw-openclaw-gateway-1 cat /home/node/.openclaw/credentials/oauth.json
```
---
## Checking logs
```bash
# Real-time sync service logs
journalctl -u sync-oauth-token.service -f
# Last 50 log entries
journalctl -u sync-oauth-token.service -n 50
# Gateway container logs
docker logs openclaw-openclaw-gateway-1 --tail 100
# Force a re-sync
systemctl restart sync-oauth-token.service
```
---
## Complete reset procedure
If everything is broken:
1. Get a fresh token: `docker exec -it claude-proxy claude login`
2. Fix auth profiles: `./scripts/fix-auth-profiles.sh`
3. Restart sync service: `systemctl restart sync-oauth-token.service`
4. Wait 10 seconds for sync to complete
5. Verify: `./scripts/verify.sh`

153
scripts/fix-auth-profiles.sh Executable file
View File

@ -0,0 +1,153 @@
#!/bin/bash
# fix-auth-profiles.sh — Fix broken Anthropic auth profiles in all OpenClaw agents
#
# Problem: Auth profiles may have {type:"oauth", key:"sk-ant-oat01-..."}
# but OpenClaw's isValidProfile() for type:"oauth" checks for "access" field, not "key"
# This causes the profile to be skipped and fall through to env var fallback
#
# Fix: Change "key" -> "access" field, clear cooldown stats
#
# Usage:
# ./fix-auth-profiles.sh # auto-detect config dir
# ./fix-auth-profiles.sh /path/to/.openclaw # custom config dir
set -euo pipefail
OPENCLAW_CONFIG_DIR="${1:-}"
LOG_PREFIX="[fix-auth-profiles]"
log() { echo "$LOG_PREFIX $*"; }
error() { echo "$LOG_PREFIX ERROR: $*" >&2; }
# Auto-detect config dir if not provided
if [ -z "$OPENCLAW_CONFIG_DIR" ]; then
if [ -d "/root/.openclaw/agents" ]; then
OPENCLAW_CONFIG_DIR="/root/.openclaw"
elif [ -d "$HOME/.openclaw/agents" ]; then
OPENCLAW_CONFIG_DIR="$HOME/.openclaw"
else
error "Cannot find OpenClaw config directory. Provide path as argument."
exit 1
fi
fi
log "Config directory: $OPENCLAW_CONFIG_DIR"
AGENTS_DIR="$OPENCLAW_CONFIG_DIR/agents"
if [ ! -d "$AGENTS_DIR" ]; then
error "Agents directory not found: $AGENTS_DIR"
exit 1
fi
# Get current token from .env if available
TOKEN=""
for env_file in /root/openclaw/.env "$OPENCLAW_CONFIG_DIR/../openclaw/.env"; do
if [ -f "$env_file" ]; then
TOKEN=$(grep -oP 'ANTHROPIC_OAUTH_TOKEN="\K[^"]+' "$env_file" 2>/dev/null || true)
[ -n "$TOKEN" ] && break
fi
done
FIXED=0
SKIPPED=0
for agent_dir in "$AGENTS_DIR"/*/agent; do
agent=$(basename "$(dirname "$agent_dir")")
f="$agent_dir/auth-profiles.json"
if [ ! -f "$f" ]; then
log "SKIP $agent: no auth-profiles.json"
SKIPPED=$((SKIPPED + 1))
continue
fi
log "Checking $agent..."
python3 -c "
import json, sys
with open('$f') as fh:
data = json.load(fh)
changed = False
# Fix profile structure
if 'anthropic:default' in data.get('profiles', {}):
p = data['profiles']['anthropic:default']
# Ensure type is oauth
if p.get('type') != 'oauth':
p['type'] = 'oauth'
changed = True
# Ensure provider is anthropic
if p.get('provider') != 'anthropic':
p['provider'] = 'anthropic'
changed = True
# Move key -> access if needed
if 'key' in p and 'access' not in p:
p['access'] = p.pop('key')
changed = True
print(' Fixed: key -> access')
elif 'key' in p and 'access' in p:
del p['key']
changed = True
print(' Fixed: removed duplicate key field')
# Update token if provided
token = '$TOKEN'
if token and p.get('access') != token:
p['access'] = token
changed = True
print(f' Updated token: {token[:20]}...')
if not changed:
print(' Already correct')
else:
# Create the profile if it doesn't exist
token = '$TOKEN'
if token:
if 'profiles' not in data:
data['profiles'] = {}
data['profiles']['anthropic:default'] = {
'type': 'oauth',
'provider': 'anthropic',
'access': token
}
changed = True
print(' Created anthropic:default profile')
else:
print(' No anthropic:default profile and no token to create one')
sys.exit(0)
# Clear cooldown for anthropic profile
if 'usageStats' in data and 'anthropic:default' in data['usageStats']:
stats = data['usageStats']['anthropic:default']
for key in ['cooldownUntil', 'errorCount', 'failureCounts', 'lastFailureAt']:
if key in stats:
del stats[key]
changed = True
if changed:
print(' Cleared cooldown stats')
# Ensure lastGood points to anthropic:default
if data.get('lastGood', {}).get('anthropic') != 'anthropic:default':
if 'lastGood' not in data:
data['lastGood'] = {}
data['lastGood']['anthropic'] = 'anthropic:default'
changed = True
if changed:
with open('$f', 'w') as fh:
json.dump(data, fh, indent=2)
print(' Saved')
"
FIXED=$((FIXED + 1))
done
log ""
log "Done. Fixed: $FIXED agents, Skipped: $SKIPPED"
log ""
log "Restart gateway to apply changes:"
log " cd /root/openclaw && docker compose down openclaw-gateway && docker compose up -d openclaw-gateway"

125
scripts/refresh-claude-token.sh Executable file
View File

@ -0,0 +1,125 @@
#!/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"

142
scripts/sync-oauth-token.sh Executable file
View 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

112
scripts/trigger-claude-refresh.sh Executable file
View File

@ -0,0 +1,112 @@
#!/bin/bash
# trigger-claude-refresh.sh — Triggers Claude CLI to refresh OAuth token when near expiry
#
# Runs via systemd timer every 30 minutes.
# Checks token expiry, triggers CLI only when needed.
# The existing sync-oauth-token.sh (inotifywait) handles syncing to OpenClaw.
#
# Supports two modes:
# Container mode: Claude CLI runs inside a Docker container
# Host mode: Claude CLI is installed directly on the system
set -uo pipefail
CREDS_FILE="@@CREDS_FILE@@"
REAUTH_FLAG="@@REAUTH_FLAG@@"
CLI_MODE="@@CLI_MODE@@"
CLI_CONTAINER="@@CLI_CONTAINER@@"
CLI_BASE_URL_OVERRIDE="@@CLI_BASE_URL_OVERRIDE@@"
LOG_PREFIX="[trigger-refresh]"
THRESHOLD_HOURS=1.5
TIMEOUT_SECONDS=60
log() { echo "$LOG_PREFIX $*"; }
log_err() { echo "$LOG_PREFIX ERROR: $*" >&2; }
# Check prerequisites
if [ ! -f "$CREDS_FILE" ]; then
log_err "Credentials file not found: $CREDS_FILE"
exit 1
fi
if [ "$CLI_MODE" = "container" ]; then
if ! docker ps --filter "name=$CLI_CONTAINER" --format '{{.Names}}' | grep -q "$CLI_CONTAINER"; then
log_err "Container $CLI_CONTAINER is not running"
exit 1
fi
else
if ! command -v claude &>/dev/null; then
log_err "Claude CLI not found on system"
exit 1
fi
fi
# Read expiry and decide whether to trigger
REMAINING=$(python3 -c "
import json, time
with open('$CREDS_FILE') as f:
d = json.load(f)
expires = d.get('claudeAiOauth', {}).get('expiresAt', 0)
remaining = (expires / 1000 - time.time()) / 3600
print(f'{remaining:.2f}')
")
log "Token expires in ${REMAINING}h (threshold: ${THRESHOLD_HOURS}h)"
# Compare as integers (multiply by 100 to avoid bash float issues)
REMAINING_X100=$(python3 -c "print(int(float('$REMAINING') * 100))")
THRESHOLD_X100=$(python3 -c "print(int(float('$THRESHOLD_HOURS') * 100))")
if [ "$REMAINING_X100" -gt "$THRESHOLD_X100" ]; then
log "Token still fresh, nothing to do"
exit 0
fi
log "Token near expiry, triggering Claude CLI refresh..."
# Record mtime BEFORE
MTIME_BEFORE=$(stat -c %Y "$CREDS_FILE" 2>/dev/null || stat -f %m "$CREDS_FILE" 2>/dev/null)
# Trigger Claude CLI
if [ "$CLI_MODE" = "container" ]; then
if [ "$CLI_BASE_URL_OVERRIDE" = "true" ]; then
CLI_OUTPUT=$(timeout "$TIMEOUT_SECONDS" docker exec \
-e ANTHROPIC_BASE_URL=https://api.anthropic.com \
"$CLI_CONTAINER" claude -p "say ok" --no-session-persistence 2>&1)
else
CLI_OUTPUT=$(timeout "$TIMEOUT_SECONDS" docker exec \
"$CLI_CONTAINER" claude -p "say ok" --no-session-persistence 2>&1)
fi
else
CLI_OUTPUT=$(timeout "$TIMEOUT_SECONDS" claude -p "say ok" --no-session-persistence 2>&1)
fi
CLI_EXIT=$?
if [ "$CLI_EXIT" -eq 124 ]; then
log_err "CLI command timed out after ${TIMEOUT_SECONDS}s"
fi
log "CLI exit code: $CLI_EXIT, output: $CLI_OUTPUT"
# Record mtime AFTER
sleep 2
MTIME_AFTER=$(stat -c %Y "$CREDS_FILE" 2>/dev/null || stat -f %m "$CREDS_FILE" 2>/dev/null)
if [ "$MTIME_BEFORE" != "$MTIME_AFTER" ]; then
log "Token refreshed successfully (mtime changed: $MTIME_BEFORE -> $MTIME_AFTER)"
if [ -f "$REAUTH_FLAG" ]; then
rm -f "$REAUTH_FLAG"
log "Cleared previous REAUTH_NEEDED flag"
fi
exit 0
else
log_err "Token refresh FAILED — credentials.json was not updated"
log_err "Re-authentication may be required (refresh token expired or subscription issue)"
echo "Re-authentication needed at $(date -u '+%Y-%m-%dT%H:%M:%SZ')" > "$REAUTH_FLAG"
log_err "Wrote $REAUTH_FLAG"
exit 1
fi

46
scripts/uninstall.sh Executable file
View File

@ -0,0 +1,46 @@
#!/bin/bash
# uninstall.sh — Remove the OAuth token sync service
set -uo pipefail
LOG_PREFIX="[uninstall]"
log() { echo "$LOG_PREFIX $*"; }
echo ""
echo "Removing OAuth Token Sync for OpenClaw"
echo "======================================="
echo ""
# Stop and disable systemd services
for svc in sync-oauth-token.service refresh-claude-token.service refresh-claude-token.timer trigger-claude-refresh.service trigger-claude-refresh.timer; do
if systemctl is-active --quiet "$svc" 2>/dev/null; then
log "Stopping $svc..."
systemctl stop "$svc"
fi
if systemctl is-enabled --quiet "$svc" 2>/dev/null; then
log "Disabling $svc..."
systemctl disable "$svc"
fi
if [ -f "/etc/systemd/system/$svc" ]; then
log "Removing /etc/systemd/system/$svc"
rm -f "/etc/systemd/system/$svc"
fi
done
systemctl daemon-reload 2>/dev/null
# Remove installed scripts
for script in /usr/local/bin/sync-oauth-token.sh /usr/local/bin/refresh-claude-token.sh /usr/local/bin/trigger-claude-refresh.sh; do
if [ -f "$script" ]; then
log "Removing $script"
rm -f "$script"
fi
done
echo ""
log "Done. The following files were NOT removed (contain your credentials):"
log " - /root/.openclaw/credentials/oauth.json"
log " - /root/openclaw/.env (ANTHROPIC_OAUTH_TOKEN)"
log " - /root/.openclaw/agents/*/agent/auth-profiles.json"
echo ""
log "To fully clean up, remove those manually if needed."

172
scripts/verify.sh Executable file
View File

@ -0,0 +1,172 @@
#!/bin/bash
# verify.sh — Post-install health check for OAuth token sync
# Run anytime to check if everything is working correctly
set -uo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
pass() { echo -e " ${GREEN}[PASS]${NC} $*"; }
fail() { echo -e " ${RED}[FAIL]${NC} $*"; }
warn() { echo -e " ${YELLOW}[WARN]${NC} $*"; }
ERRORS=0
echo ""
echo "=========================================="
echo " OAuth Token Sync — Health Check"
echo "=========================================="
echo ""
# --- 1. Systemd service ---
echo "1. Systemd service status"
if systemctl is-active --quiet sync-oauth-token.service 2>/dev/null; then
pass "sync-oauth-token.service is active"
else
fail "sync-oauth-token.service is not running"
echo " Fix: systemctl start sync-oauth-token.service"
ERRORS=$((ERRORS + 1))
fi
if systemctl is-enabled --quiet sync-oauth-token.service 2>/dev/null; then
pass "Service is enabled (starts on boot)"
else
warn "Service is not enabled for boot"
echo " Fix: systemctl enable sync-oauth-token.service"
fi
echo ""
# --- 2. inotifywait process ---
echo "2. File watcher process"
if pgrep -f inotifywait > /dev/null 2>&1; then
WATCH_PATH=$(pgrep -af inotifywait | grep -oP '/[^ ]+' | tail -1)
pass "inotifywait is running (watching: $WATCH_PATH)"
else
fail "inotifywait is not running"
ERRORS=$((ERRORS + 1))
fi
echo ""
# --- 3. Source credentials file ---
echo "3. Claude CLI credentials file"
# Try to find the watched file from the service
SYNC_SCRIPT=$(which sync-oauth-token.sh 2>/dev/null || echo "/usr/local/bin/sync-oauth-token.sh")
if [ -f "$SYNC_SCRIPT" ]; then
SOURCE_FILE=$(grep 'CLAUDE_CREDS_FILE=' "$SYNC_SCRIPT" | head -1 | cut -d'"' -f2)
fi
SOURCE_FILE="${SOURCE_FILE:-/root/.openclaw/workspaces/workspace-claude-proxy/config/.claude/.credentials.json}"
if [ -f "$SOURCE_FILE" ]; then
pass "File exists: $SOURCE_FILE"
EXPIRES=$(python3 -c "
import json, time
with open('$SOURCE_FILE') as f:
d = json.load(f)
exp = d['claudeAiOauth']['expiresAt'] / 1000
remaining = (exp - time.time()) / 3600
status = 'VALID' if remaining > 0 else 'EXPIRED'
print(f'{remaining:.1f}h remaining ({status})')
" 2>/dev/null || echo "parse error")
if echo "$EXPIRES" | grep -q "VALID"; then
pass "Token: $EXPIRES"
else
fail "Token: $EXPIRES"
ERRORS=$((ERRORS + 1))
fi
else
fail "File not found: $SOURCE_FILE"
ERRORS=$((ERRORS + 1))
fi
echo ""
# --- 4. OpenClaw oauth.json ---
echo "4. OpenClaw oauth.json"
for path in /root/.openclaw/credentials/oauth.json /home/*/.openclaw/credentials/oauth.json; do
if [ -f "$path" ]; then
OAUTH_FILE="$path"
break
fi
done
if [ -n "${OAUTH_FILE:-}" ] && [ -f "$OAUTH_FILE" ]; then
HAS_ACCESS=$(python3 -c "
import json
with open('$OAUTH_FILE') as f:
d = json.load(f)
a = d.get('anthropic', {})
print('yes' if a.get('access') else 'no')
" 2>/dev/null || echo "no")
if [ "$HAS_ACCESS" = "yes" ]; then
pass "oauth.json exists with anthropic.access field: $OAUTH_FILE"
else
fail "oauth.json exists but missing anthropic.access field"
ERRORS=$((ERRORS + 1))
fi
else
fail "oauth.json not found"
ERRORS=$((ERRORS + 1))
fi
echo ""
# --- 5. .env file ---
echo "5. Environment file (.env)"
for path in /root/openclaw/.env; do
if [ -f "$path" ]; then
ENV_FILE="$path"
break
fi
done
if [ -n "${ENV_FILE:-}" ] && [ -f "$ENV_FILE" ]; then
if grep -q 'ANTHROPIC_OAUTH_TOKEN=' "$ENV_FILE"; then
TOKEN_PREFIX=$(grep 'ANTHROPIC_OAUTH_TOKEN=' "$ENV_FILE" | head -1 | cut -d'"' -f2 | cut -c1-20)
pass ".env has ANTHROPIC_OAUTH_TOKEN: ${TOKEN_PREFIX}..."
else
fail ".env missing ANTHROPIC_OAUTH_TOKEN"
ERRORS=$((ERRORS + 1))
fi
else
fail ".env file not found"
ERRORS=$((ERRORS + 1))
fi
echo ""
# --- 6. Gateway container ---
echo "6. Gateway container"
GATEWAY=$(docker ps --filter name=openclaw --format '{{.Names}}' 2>/dev/null | grep gateway | head -1)
if [ -n "$GATEWAY" ]; then
UPTIME=$(docker ps --filter "name=$GATEWAY" --format '{{.Status}}' 2>/dev/null)
pass "Gateway running: $GATEWAY ($UPTIME)"
# Check container env var matches .env
CONTAINER_TOKEN=$(docker exec "$GATEWAY" printenv ANTHROPIC_OAUTH_TOKEN 2>/dev/null | cut -c1-20)
if [ -n "$CONTAINER_TOKEN" ]; then
pass "Container has ANTHROPIC_OAUTH_TOKEN: ${CONTAINER_TOKEN}..."
else
warn "Container missing ANTHROPIC_OAUTH_TOKEN env var"
fi
else
fail "No OpenClaw gateway container found"
ERRORS=$((ERRORS + 1))
fi
echo ""
# --- Summary ---
echo "=========================================="
if [ "$ERRORS" -eq 0 ]; then
echo -e " ${GREEN}All checks passed${NC}"
else
echo -e " ${RED}$ERRORS check(s) failed${NC}"
fi
echo "=========================================="
echo ""
echo "Useful commands:"
echo " journalctl -u sync-oauth-token.service -f # Watch sync logs"
echo " systemctl restart sync-oauth-token.service # Force re-sync"
echo " ./scripts/verify.sh # Run this check again"
echo ""
exit $ERRORS

852
setup.sh Executable file
View File

@ -0,0 +1,852 @@
#!/bin/bash
# ============================================================================
# OAuth Fix for OpenClaw — Interactive Setup Wizard
# ============================================================================
# Configures automatic Anthropic OAuth token refresh for OpenClaw.
# Detects paths, installs the sync service, and configures the Anthropic model.
#
# Usage:
# ./setup.sh # Interactive mode
# ./setup.sh --uninstall # Remove everything
# ============================================================================
set -euo pipefail
VERSION="1.0.0"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# --- Colors ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m'
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
success() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
header() { echo -e "\n${BOLD}${CYAN}━━━ $* ━━━${NC}\n"; }
ask() {
local prompt="$1"
local default="${2:-}"
local result
if [ -n "$default" ]; then
read -rp "$(echo -e "${BOLD}$prompt${NC} [$default]: ")" result
echo "${result:-$default}"
else
read -rp "$(echo -e "${BOLD}$prompt${NC}: ")" result
echo "$result"
fi
}
confirm() {
local prompt="$1"
local default="${2:-Y}"
local result
read -rp "$(echo -e "${BOLD}$prompt${NC} [${default}]: ")" result
result="${result:-$default}"
[[ "$result" =~ ^[Yy] ]]
}
# --- Uninstall ---
if [ "${1:-}" = "--uninstall" ]; then
bash "$SCRIPT_DIR/scripts/uninstall.sh"
exit $?
fi
# ============================================================================
# BANNER
# ============================================================================
echo ""
echo -e "${BOLD}${CYAN}"
echo " ╔══════════════════════════════════════════════════════╗"
echo " ║ OAuth Fix for OpenClaw + Claude Max ║"
echo " ║ Automatic Anthropic Token Refresh ║"
echo " ║ v${VERSION}"
echo " ╚══════════════════════════════════════════════════════╝"
echo -e "${NC}"
echo -e "${DIM} Keeps your Anthropic OAuth tokens fresh by syncing"
echo -e " Claude Code CLI's auto-refreshed credentials to OpenClaw.${NC}"
echo ""
# ============================================================================
# STEP 1: Prerequisites
# ============================================================================
header "Step 1: Checking Prerequisites"
MISSING=0
for cmd in docker python3 curl systemctl; do
if command -v "$cmd" &>/dev/null; then
success "$cmd found"
else
error "$cmd not found"
MISSING=$((MISSING + 1))
fi
done
# Check docker compose (v2)
if docker compose version &>/dev/null; then
success "docker compose found ($(docker compose version --short 2>/dev/null || echo 'v2'))"
else
error "docker compose v2 not found"
MISSING=$((MISSING + 1))
fi
# Check inotifywait
if command -v inotifywait &>/dev/null; then
success "inotifywait found"
USE_INOTIFY=true
else
warn "inotifywait not found (inotify-tools package)"
echo -e " ${DIM}Install with: apt install inotify-tools${NC}"
if confirm " Install inotify-tools now?" "Y"; then
apt-get install -y inotify-tools 2>&1 | tail -1
if command -v inotifywait &>/dev/null; then
success "inotifywait installed"
USE_INOTIFY=true
else
warn "Installation failed. Will use timer-based fallback."
USE_INOTIFY=false
fi
else
warn "Will use timer-based fallback (6-hour refresh cycle)"
USE_INOTIFY=false
fi
fi
if [ "$MISSING" -gt 0 ]; then
error "$MISSING prerequisite(s) missing. Please install them and re-run."
exit 1
fi
# ============================================================================
# STEP 2: Detect OpenClaw Installation
# ============================================================================
header "Step 2: Detecting OpenClaw Installation"
# Find openclaw.json
OPENCLAW_CONFIG_DIR=""
for path in \
"${OPENCLAW_CONFIG_DIR:-}" \
/root/.openclaw \
/home/*/.openclaw; do
if [ -f "$path/openclaw.json" ] 2>/dev/null; then
OPENCLAW_CONFIG_DIR="$path"
break
fi
done
if [ -z "$OPENCLAW_CONFIG_DIR" ]; then
# Try docker inspect
GATEWAY=$(docker ps --format '{{.Names}}' 2>/dev/null | grep -i 'openclaw.*gateway' | head -1)
if [ -n "$GATEWAY" ]; then
OPENCLAW_CONFIG_DIR=$(docker inspect "$GATEWAY" --format '{{range .Mounts}}{{if eq .Destination "/home/node/.openclaw"}}{{.Source}}{{end}}{{end}}' 2>/dev/null)
fi
fi
if [ -z "$OPENCLAW_CONFIG_DIR" ] || [ ! -f "$OPENCLAW_CONFIG_DIR/openclaw.json" ]; then
error "Cannot find OpenClaw installation"
OPENCLAW_CONFIG_DIR=$(ask "Enter OpenClaw config directory (contains openclaw.json)")
if [ ! -f "$OPENCLAW_CONFIG_DIR/openclaw.json" ]; then
error "openclaw.json not found in $OPENCLAW_CONFIG_DIR"
exit 1
fi
fi
success "Config: $OPENCLAW_CONFIG_DIR/openclaw.json"
# Find docker-compose directory
COMPOSE_DIR=""
for path in \
/root/openclaw \
"$(dirname "$OPENCLAW_CONFIG_DIR")/openclaw" \
/opt/openclaw; do
if [ -f "$path/docker-compose.yml" ] || [ -f "$path/docker-compose.yaml" ] || [ -f "$path/compose.yml" ]; then
COMPOSE_DIR="$path"
break
fi
done
if [ -z "$COMPOSE_DIR" ]; then
COMPOSE_DIR=$(ask "Enter OpenClaw docker-compose directory" "/root/openclaw")
fi
success "Compose: $COMPOSE_DIR"
# Find .env file
ENV_FILE="$COMPOSE_DIR/.env"
if [ ! -f "$ENV_FILE" ]; then
warn ".env not found at $ENV_FILE"
ENV_FILE=$(ask "Enter .env file path" "$COMPOSE_DIR/.env")
fi
success "Env: $ENV_FILE"
# Find gateway container
GATEWAY_CONTAINER=$(docker ps --format '{{.Names}}' 2>/dev/null | grep -i 'openclaw.*gateway' | head -1)
if [ -z "$GATEWAY_CONTAINER" ]; then
GATEWAY_CONTAINER=$(ask "Enter gateway container name" "openclaw-openclaw-gateway-1")
fi
success "Gateway: $GATEWAY_CONTAINER"
# Derive oauth.json path
OPENCLAW_OAUTH_FILE="$OPENCLAW_CONFIG_DIR/credentials/oauth.json"
success "OAuth file: $OPENCLAW_OAUTH_FILE"
# ============================================================================
# STEP 3: Detect Claude CLI Credentials
# ============================================================================
header "Step 3: Detecting Claude CLI Credentials"
CLAUDE_CREDS_FILE=""
# Strategy 1: Find claude-proxy or similar container
CLAUDE_CONTAINER=$(docker ps --format '{{.Names}}' 2>/dev/null | grep -i 'claude' | head -1)
if [ -n "$CLAUDE_CONTAINER" ]; then
info "Found Claude container: $CLAUDE_CONTAINER"
# Get its volume mounts and find credentials
MOUNT_SOURCE=$(docker inspect "$CLAUDE_CONTAINER" --format '{{range .Mounts}}{{if or (eq .Destination "/root") (eq .Destination "/root/.claude") (eq .Destination "/home/node/.claude")}}{{.Source}}{{end}}{{end}}' 2>/dev/null)
if [ -n "$MOUNT_SOURCE" ]; then
for suffix in "/.claude/.credentials.json" "/.credentials.json"; do
if [ -f "${MOUNT_SOURCE}${suffix}" ]; then
CLAUDE_CREDS_FILE="${MOUNT_SOURCE}${suffix}"
break
fi
done
fi
fi
# Strategy 2: Search workspace directories
if [ -z "$CLAUDE_CREDS_FILE" ]; then
for path in "$OPENCLAW_CONFIG_DIR"/workspaces/*/config/.claude/.credentials.json; do
if [ -f "$path" ]; then
CLAUDE_CREDS_FILE="$path"
break
fi
done
fi
# Strategy 3: Direct paths
if [ -z "$CLAUDE_CREDS_FILE" ]; then
for path in \
/root/.claude/.credentials.json \
"$HOME/.claude/.credentials.json"; do
if [ -f "$path" ]; then
CLAUDE_CREDS_FILE="$path"
break
fi
done
fi
if [ -z "$CLAUDE_CREDS_FILE" ] || [ ! -f "$CLAUDE_CREDS_FILE" ]; then
warn "Could not auto-detect Claude CLI credentials"
CLAUDE_CREDS_FILE=$(ask "Enter path to Claude CLI .credentials.json")
if [ ! -f "$CLAUDE_CREDS_FILE" ]; then
error "File not found: $CLAUDE_CREDS_FILE"
exit 1
fi
fi
# Validate credentials
TOKEN_INFO=$(python3 -c "
import json, time
with open('$CLAUDE_CREDS_FILE') as f:
d = json.load(f)
oauth = d.get('claudeAiOauth', {})
access = oauth.get('accessToken', '')
refresh = oauth.get('refreshToken', '')
expires = oauth.get('expiresAt', 0)
remaining = (expires / 1000 - time.time()) / 3600
status = 'VALID' if remaining > 0 else 'EXPIRED'
print(f'access={access[:20]}... refresh={\"yes\" if refresh else \"no\"} remaining={remaining:.1f}h status={status}')
" 2>/dev/null || echo "error")
if echo "$TOKEN_INFO" | grep -q "error"; then
error "Cannot parse credentials file. Is it a valid Claude CLI .credentials.json?"
exit 1
fi
success "Credentials: $CLAUDE_CREDS_FILE"
info " $TOKEN_INFO"
CURRENT_TOKEN=$(python3 -c "
import json
with open('$CLAUDE_CREDS_FILE') as f:
d = json.load(f)
print(d['claudeAiOauth']['accessToken'])
" 2>/dev/null)
# ============================================================================
# STEP 4: Confirm Configuration
# ============================================================================
header "Step 4: Configuration Summary"
echo -e " ${BOLD}OpenClaw config:${NC} $OPENCLAW_CONFIG_DIR"
echo -e " ${BOLD}Docker compose:${NC} $COMPOSE_DIR"
echo -e " ${BOLD}Environment file:${NC} $ENV_FILE"
echo -e " ${BOLD}Gateway container:${NC} $GATEWAY_CONTAINER"
echo -e " ${BOLD}OAuth file:${NC} $OPENCLAW_OAUTH_FILE"
echo -e " ${BOLD}Claude CLI creds:${NC} $CLAUDE_CREDS_FILE"
echo -e " ${BOLD}Sync method:${NC} $([ "$USE_INOTIFY" = true ] && echo 'inotifywait (real-time)' || echo 'systemd timer (every 6h)')"
echo ""
if ! confirm "Proceed with these settings?" "Y"; then
echo "Aborted."
exit 0
fi
# ============================================================================
# STEP 5: Configure Anthropic Model in OpenClaw
# ============================================================================
header "Step 5: Configuring Anthropic Model"
# Check if anthropic model is already configured
HAS_ANTHROPIC=$(python3 -c "
import json
with open('$OPENCLAW_CONFIG_DIR/openclaw.json') as f:
d = json.load(f)
primary = d.get('agents', {}).get('defaults', {}).get('model', {}).get('primary', '')
print('yes' if 'anthropic/' in primary else 'no')
" 2>/dev/null || echo "no")
if [ "$HAS_ANTHROPIC" = "yes" ]; then
success "Anthropic model already configured as primary"
else
info "Anthropic model not yet configured"
if confirm " Set anthropic/claude-opus-4-6 as primary model?" "Y"; then
python3 -c "
import json
with open('$OPENCLAW_CONFIG_DIR/openclaw.json') as f:
d = json.load(f)
# Set primary model
if 'agents' not in d:
d['agents'] = {}
if 'defaults' not in d['agents']:
d['agents']['defaults'] = {}
if 'model' not in d['agents']['defaults']:
d['agents']['defaults']['model'] = {}
d['agents']['defaults']['model']['primary'] = 'anthropic/claude-opus-4-6'
# Add fallback if not present
fallbacks = d['agents']['defaults']['model'].get('fallbacks', [])
if 'anthropic/claude-sonnet-4-6' not in fallbacks:
fallbacks.insert(0, 'anthropic/claude-sonnet-4-6')
d['agents']['defaults']['model']['fallbacks'] = fallbacks
# Add model aliases
if 'models' not in d['agents']['defaults']:
d['agents']['defaults']['models'] = {}
d['agents']['defaults']['models']['anthropic/claude-opus-4-6'] = {'alias': 'Claude Opus 4.6 (Max)'}
d['agents']['defaults']['models']['anthropic/claude-sonnet-4-6'] = {'alias': 'Claude Sonnet 4.6 (Max)'}
with open('$OPENCLAW_CONFIG_DIR/openclaw.json', 'w') as f:
json.dump(d, f, indent=2)
"
success "Set anthropic/claude-opus-4-6 as primary model"
success "Added anthropic/claude-sonnet-4-6 to fallbacks"
success "Added model aliases"
fi
fi
# Check for broken custom anthropic provider (common mistake)
HAS_CUSTOM_PROVIDER=$(python3 -c "
import json
with open('$OPENCLAW_CONFIG_DIR/openclaw.json') as f:
d = json.load(f)
providers = d.get('models', {}).get('providers', {})
print('yes' if 'anthropic' in providers else 'no')
" 2>/dev/null || echo "no")
if [ "$HAS_CUSTOM_PROVIDER" = "yes" ]; then
warn "Found custom 'anthropic' in models.providers — this causes 404 errors!"
warn "The built-in provider handles Anthropic. Custom entry causes double /v1 in URLs."
if confirm " Remove the custom anthropic provider entry?" "Y"; then
python3 -c "
import json
with open('$OPENCLAW_CONFIG_DIR/openclaw.json') as f:
d = json.load(f)
del d['models']['providers']['anthropic']
with open('$OPENCLAW_CONFIG_DIR/openclaw.json', 'w') as f:
json.dump(d, f, indent=2)
"
success "Removed custom anthropic provider"
fi
fi
# Add ANTHROPIC_OAUTH_TOKEN to .env if missing
if grep -q 'ANTHROPIC_OAUTH_TOKEN=' "$ENV_FILE" 2>/dev/null; then
success "ANTHROPIC_OAUTH_TOKEN already in .env"
else
echo "ANTHROPIC_OAUTH_TOKEN=\"$CURRENT_TOKEN\"" >> "$ENV_FILE"
success "Added ANTHROPIC_OAUTH_TOKEN to .env"
fi
# ============================================================================
# STEP 6: Fix/Create Auth Profiles
# ============================================================================
header "Step 6: Configuring Auth Profiles"
AGENTS_DIR="$OPENCLAW_CONFIG_DIR/agents"
if [ -d "$AGENTS_DIR" ]; then
for agent_dir in "$AGENTS_DIR"/*/agent; do
[ -d "$agent_dir" ] || continue
agent=$(basename "$(dirname "$agent_dir")")
f="$agent_dir/auth-profiles.json"
if [ ! -f "$f" ]; then
# Create new auth-profiles.json
python3 -c "
import json
data = {
'version': 1,
'profiles': {
'anthropic:default': {
'type': 'oauth',
'provider': 'anthropic',
'access': '$CURRENT_TOKEN'
}
},
'lastGood': {
'anthropic': 'anthropic:default'
},
'usageStats': {}
}
with open('$f', 'w') as fh:
json.dump(data, fh, indent=2)
"
success "$agent: created auth-profiles.json"
else
# Fix existing profile
python3 -c "
import json
with open('$f') as fh:
data = json.load(fh)
changed = False
if 'profiles' not in data:
data['profiles'] = {}
p = data['profiles'].get('anthropic:default', {})
if not p:
data['profiles']['anthropic:default'] = {
'type': 'oauth',
'provider': 'anthropic',
'access': '$CURRENT_TOKEN'
}
changed = True
else:
if p.get('type') != 'oauth':
p['type'] = 'oauth'
changed = True
if 'key' in p and 'access' not in p:
p['access'] = p.pop('key')
changed = True
elif 'key' in p:
del p['key']
changed = True
if p.get('access') != '$CURRENT_TOKEN':
p['access'] = '$CURRENT_TOKEN'
changed = True
# Clear cooldown
stats = data.get('usageStats', {}).get('anthropic:default', {})
for k in ['cooldownUntil', 'errorCount', 'failureCounts', 'lastFailureAt']:
if k in stats:
del stats[k]
changed = True
if 'lastGood' not in data:
data['lastGood'] = {}
data['lastGood']['anthropic'] = 'anthropic:default'
if changed:
with open('$f', 'w') as fh:
json.dump(data, fh, indent=2)
print('fixed')
else:
print('ok')
" 2>/dev/null
RESULT=$(python3 -c "
import json
with open('$f') as fh:
data = json.load(fh)
p = data.get('profiles', {}).get('anthropic:default', {})
has_access = 'access' in p
print(f'type={p.get(\"type\",\"none\")} access={\"yes\" if has_access else \"no\"}')
" 2>/dev/null)
success "$agent: $RESULT"
fi
done
else
warn "No agents directory found at $AGENTS_DIR"
fi
# ============================================================================
# STEP 7: Install Sync Script
# ============================================================================
header "Step 7: Installing Sync Service"
INSTALL_DIR="/usr/local/bin"
if [ "$USE_INOTIFY" = true ]; then
# Install inotify-based watcher
SYNC_SCRIPT="$INSTALL_DIR/sync-oauth-token.sh"
sed \
-e "s|@@CLAUDE_CREDS_FILE@@|$CLAUDE_CREDS_FILE|g" \
-e "s|@@OPENCLAW_OAUTH_FILE@@|$OPENCLAW_OAUTH_FILE|g" \
-e "s|@@OPENCLAW_ENV_FILE@@|$ENV_FILE|g" \
-e "s|@@COMPOSE_DIR@@|$COMPOSE_DIR|g" \
"$SCRIPT_DIR/scripts/sync-oauth-token.sh" > "$SYNC_SCRIPT"
chmod +x "$SYNC_SCRIPT"
success "Installed $SYNC_SCRIPT"
# Install systemd service
sed "s|@@SYNC_SCRIPT_PATH@@|$SYNC_SCRIPT|g" \
"$SCRIPT_DIR/templates/sync-oauth-token.service" > /etc/systemd/system/sync-oauth-token.service
success "Installed systemd service"
systemctl daemon-reload
systemctl enable sync-oauth-token.service
systemctl start sync-oauth-token.service
success "Service started and enabled"
else
# Install timer-based refresh
REFRESH_SCRIPT="$INSTALL_DIR/refresh-claude-token.sh"
sed \
-e "s|@@CLAUDE_CREDS_FILE@@|$CLAUDE_CREDS_FILE|g" \
-e "s|@@OPENCLAW_OAUTH_FILE@@|$OPENCLAW_OAUTH_FILE|g" \
-e "s|@@OPENCLAW_ENV_FILE@@|$ENV_FILE|g" \
-e "s|@@COMPOSE_DIR@@|$COMPOSE_DIR|g" \
"$SCRIPT_DIR/scripts/refresh-claude-token.sh" > "$REFRESH_SCRIPT"
chmod +x "$REFRESH_SCRIPT"
success "Installed $REFRESH_SCRIPT"
# Install systemd timer
sed "s|@@REFRESH_SCRIPT_PATH@@|$REFRESH_SCRIPT|g" \
"$SCRIPT_DIR/templates/refresh-claude-token.service" > /etc/systemd/system/refresh-claude-token.service
cp "$SCRIPT_DIR/templates/refresh-claude-token.timer" /etc/systemd/system/
success "Installed systemd timer"
systemctl daemon-reload
systemctl enable refresh-claude-token.timer
systemctl start refresh-claude-token.timer
success "Timer started and enabled (runs every 6 hours)"
fi
# ============================================================================
# STEP 8: Detect Claude CLI & Install Auto-Refresh Trigger
# ============================================================================
header "Step 8: Detecting Claude CLI & Installing Auto-Refresh Trigger"
info "The sync service watches for credential changes, but something must"
info "trigger Claude CLI to actually refresh the token before it expires."
info "This step installs a timer that triggers the refresh automatically."
echo ""
# Detect Claude CLI — container or host?
CLI_MODE=""
CLI_CONTAINER=""
CLI_BASE_URL_OVERRIDE="false"
# Strategy 1: Check for Claude container
CLAUDE_CONTAINER_FOUND=$(docker ps --format '{{.Names}}' 2>/dev/null | grep -i 'claude' | head -1)
if [ -n "$CLAUDE_CONTAINER_FOUND" ]; then
# Verify claude binary exists inside
if docker exec "$CLAUDE_CONTAINER_FOUND" which claude &>/dev/null; then
CLI_MODE="container"
CLI_CONTAINER="$CLAUDE_CONTAINER_FOUND"
info "Found Claude CLI in container: $CLI_CONTAINER"
# Check if ANTHROPIC_BASE_URL needs override
CTR_BASE_URL=$(docker inspect "$CLI_CONTAINER" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | grep '^ANTHROPIC_BASE_URL=' | cut -d= -f2-)
if [ -n "$CTR_BASE_URL" ] && [ "$CTR_BASE_URL" != "https://api.anthropic.com" ]; then
CLI_BASE_URL_OVERRIDE="true"
info "Container has ANTHROPIC_BASE_URL=$CTR_BASE_URL"
info "Will use temporary override for CLI invocations (does not affect container)"
fi
fi
fi
# Strategy 2: Check host
if [ -z "$CLI_MODE" ]; then
if command -v claude &>/dev/null; then
CLI_MODE="host"
info "Found Claude CLI on host: $(which claude)"
fi
fi
# Strategy 3: Ask user
if [ -z "$CLI_MODE" ]; then
warn "Could not auto-detect Claude CLI"
echo ""
echo " Where is Claude CLI installed?"
echo " 1) In a Docker container"
echo " 2) Directly on this system"
echo " 3) Skip (no auto-refresh trigger)"
CHOICE=$(ask "Select" "3")
case "$CHOICE" in
1)
CLI_MODE="container"
CLI_CONTAINER=$(ask "Enter container name" "claude-proxy")
if ! docker exec "$CLI_CONTAINER" which claude &>/dev/null; then
error "Claude CLI not found in container $CLI_CONTAINER"
CLI_MODE=""
else
# Check base URL
CTR_BASE_URL=$(docker inspect "$CLI_CONTAINER" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | grep '^ANTHROPIC_BASE_URL=' | cut -d= -f2-)
if [ -n "$CTR_BASE_URL" ] && [ "$CTR_BASE_URL" != "https://api.anthropic.com" ]; then
CLI_BASE_URL_OVERRIDE="true"
fi
fi
;;
2)
CLI_MODE="host"
if ! command -v claude &>/dev/null; then
error "Claude CLI not found on system"
CLI_MODE=""
fi
;;
*)
warn "Skipping auto-refresh trigger installation"
;;
esac
fi
TRIGGER_INSTALLED=false
if [ -n "$CLI_MODE" ]; then
# Test Claude CLI invocation
info "Testing Claude CLI invocation..."
if [ "$CLI_MODE" = "container" ]; then
if [ "$CLI_BASE_URL_OVERRIDE" = "true" ]; then
TEST_CMD="docker exec -e ANTHROPIC_BASE_URL=https://api.anthropic.com $CLI_CONTAINER claude -p 'say ok' --no-session-persistence"
TEST_OUTPUT=$(timeout 60 docker exec -e ANTHROPIC_BASE_URL=https://api.anthropic.com "$CLI_CONTAINER" claude -p "say ok" --no-session-persistence 2>&1)
else
TEST_CMD="docker exec $CLI_CONTAINER claude -p 'say ok' --no-session-persistence"
TEST_OUTPUT=$(timeout 60 docker exec "$CLI_CONTAINER" claude -p "say ok" --no-session-persistence 2>&1)
fi
else
TEST_CMD="claude -p 'say ok' --no-session-persistence"
TEST_OUTPUT=$(timeout 60 claude -p "say ok" --no-session-persistence 2>&1)
fi
TEST_EXIT=$?
if [ "$TEST_EXIT" -eq 0 ] && [ -n "$TEST_OUTPUT" ]; then
success "CLI test passed: $TEST_OUTPUT"
# Verify proxy/container env unchanged (if container mode)
if [ "$CLI_MODE" = "container" ] && [ "$CLI_BASE_URL_OVERRIDE" = "true" ]; then
VERIFY_URL=$(docker inspect "$CLI_CONTAINER" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | grep '^ANTHROPIC_BASE_URL=' | cut -d= -f2-)
if [ "$VERIFY_URL" = "$CTR_BASE_URL" ]; then
success "Container env unchanged (temporary override confirmed)"
else
warn "Container env may have changed — check manually"
fi
fi
# Install trigger script
TRIGGER_SCRIPT="$INSTALL_DIR/trigger-claude-refresh.sh"
REAUTH_FLAG="$COMPOSE_DIR/REAUTH_NEEDED"
sed \
-e "s|@@CREDS_FILE@@|$CLAUDE_CREDS_FILE|g" \
-e "s|@@REAUTH_FLAG@@|$REAUTH_FLAG|g" \
-e "s|@@CLI_MODE@@|$CLI_MODE|g" \
-e "s|@@CLI_CONTAINER@@|$CLI_CONTAINER|g" \
-e "s|@@CLI_BASE_URL_OVERRIDE@@|$CLI_BASE_URL_OVERRIDE|g" \
"$SCRIPT_DIR/scripts/trigger-claude-refresh.sh" > "$TRIGGER_SCRIPT"
chmod +x "$TRIGGER_SCRIPT"
success "Installed $TRIGGER_SCRIPT"
# Install systemd service and timer
sed "s|@@TRIGGER_SCRIPT_PATH@@|$TRIGGER_SCRIPT|g" \
"$SCRIPT_DIR/templates/trigger-claude-refresh.service" > /etc/systemd/system/trigger-claude-refresh.service
cp "$SCRIPT_DIR/templates/trigger-claude-refresh.timer" /etc/systemd/system/
success "Installed systemd timer"
systemctl daemon-reload
systemctl enable trigger-claude-refresh.timer
systemctl start trigger-claude-refresh.timer
success "Auto-refresh trigger timer started (runs every 30 minutes)"
TRIGGER_INSTALLED=true
# Run the trigger script once to verify
info "Running trigger script for initial verification..."
bash "$TRIGGER_SCRIPT" 2>&1 | while read -r line; do echo " $line"; done
else
error "CLI test failed (exit code: $TEST_EXIT)"
if [ -n "$TEST_OUTPUT" ]; then
error "Output: $TEST_OUTPUT"
fi
warn "Skipping auto-refresh trigger installation"
warn "You can re-run the wizard later to retry"
fi
fi
# ============================================================================
# STEP 9: Initial Sync
# ============================================================================
header "Step 9: Running Initial Sync"
# Create oauth.json
mkdir -p "$(dirname "$OPENCLAW_OAUTH_FILE")"
python3 -c "
import json, time
with open('$CLAUDE_CREDS_FILE') as f:
src = json.load(f)
oauth = src['claudeAiOauth']
openclaw = {
'anthropic': {
'access': oauth['accessToken'],
'refresh': oauth['refreshToken'],
'expires': oauth['expiresAt'],
'scopes': oauth.get('scopes', []),
'subscriptionType': oauth.get('subscriptionType', 'max'),
'rateLimitTier': oauth.get('rateLimitTier', 'default_claude_max_5x')
}
}
with open('$OPENCLAW_OAUTH_FILE', 'w') as f:
json.dump(openclaw, f)
remaining = (oauth['expiresAt'] / 1000 - time.time()) / 3600
print(f'Token: {oauth[\"accessToken\"][:20]}... expires in {remaining:.1f}h')
"
success "Created $OPENCLAW_OAUTH_FILE"
# Update .env token
python3 -c "
import json, re
with open('$CLAUDE_CREDS_FILE') as f:
token = json.load(f)['claudeAiOauth']['accessToken']
with open('$ENV_FILE') as f:
env = f.read()
env = re.sub(r'ANTHROPIC_OAUTH_TOKEN=.*', f'ANTHROPIC_OAUTH_TOKEN=\"{token}\"', env)
with open('$ENV_FILE', 'w') as f:
f.write(env)
"
success "Updated $ENV_FILE"
# Recreate gateway
info "Recreating gateway container..."
cd "$COMPOSE_DIR"
docker compose down openclaw-gateway 2>&1 | tail -2
docker compose up -d openclaw-gateway 2>&1 | tail -2
success "Gateway recreated with fresh token"
# ============================================================================
# STEP 10: Verify
# ============================================================================
header "Step 10: Verification"
sleep 3
ERRORS=0
# Check service
if [ "$USE_INOTIFY" = true ]; then
if systemctl is-active --quiet sync-oauth-token.service; then
success "sync-oauth-token.service is running"
else
error "Service not running"
ERRORS=$((ERRORS + 1))
fi
else
if systemctl is-active --quiet refresh-claude-token.timer; then
success "refresh-claude-token.timer is active"
else
error "Timer not active"
ERRORS=$((ERRORS + 1))
fi
fi
# Check trigger timer
if [ "$TRIGGER_INSTALLED" = true ]; then
if systemctl is-active --quiet trigger-claude-refresh.timer; then
success "trigger-claude-refresh.timer is active"
else
error "Trigger timer not active"
ERRORS=$((ERRORS + 1))
fi
fi
# Check oauth.json
if [ -f "$OPENCLAW_OAUTH_FILE" ]; then
HAS_ACCESS=$(python3 -c "
import json
with open('$OPENCLAW_OAUTH_FILE') as f:
d = json.load(f)
print('yes' if d.get('anthropic', {}).get('access') else 'no')
" 2>/dev/null)
if [ "$HAS_ACCESS" = "yes" ]; then
success "oauth.json has correct format"
else
error "oauth.json missing anthropic.access"
ERRORS=$((ERRORS + 1))
fi
else
error "oauth.json not found"
ERRORS=$((ERRORS + 1))
fi
# Check gateway
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "$GATEWAY_CONTAINER"; then
success "Gateway container is running"
CTR_TOKEN=$(docker exec "$GATEWAY_CONTAINER" printenv ANTHROPIC_OAUTH_TOKEN 2>/dev/null | cut -c1-20)
if [ -n "$CTR_TOKEN" ]; then
success "Container has ANTHROPIC_OAUTH_TOKEN: ${CTR_TOKEN}..."
fi
else
error "Gateway container not running"
ERRORS=$((ERRORS + 1))
fi
# ============================================================================
# SUMMARY
# ============================================================================
echo ""
echo -e "${BOLD}${CYAN}"
echo " ╔══════════════════════════════════════════════════════╗"
if [ "$ERRORS" -eq 0 ]; then
echo " ║ Setup Complete! ║"
else
echo " ║ Setup Complete (with $ERRORS warning(s)) ║"
fi
echo " ╚══════════════════════════════════════════════════════╝"
echo -e "${NC}"
echo -e " ${BOLD}Installed:${NC}"
if [ "$USE_INOTIFY" = true ]; then
echo " - sync-oauth-token.sh (real-time file watcher)"
echo " - sync-oauth-token.service (systemd)"
else
echo " - refresh-claude-token.sh (direct API refresh)"
echo " - refresh-claude-token.timer (every 6 hours)"
fi
if [ "$TRIGGER_INSTALLED" = true ]; then
echo " - trigger-claude-refresh.sh (auto-refresh trigger)"
echo " - trigger-claude-refresh.timer (every 30 minutes)"
echo " - Claude CLI mode: $CLI_MODE$([ "$CLI_MODE" = "container" ] && echo " ($CLI_CONTAINER)")"
fi
echo " - Anthropic model configured in openclaw.json"
echo " - Auth profiles updated for all agents"
echo " - oauth.json created with fresh token"
echo ""
echo -e " ${BOLD}Useful commands:${NC}"
if [ "$USE_INOTIFY" = true ]; then
echo " journalctl -u sync-oauth-token.service -f # Watch sync logs"
echo " systemctl restart sync-oauth-token.service # Force re-sync"
else
echo " journalctl -u refresh-claude-token.service # View last refresh"
echo " systemctl list-timers refresh-claude-token* # Check timer"
fi
if [ "$TRIGGER_INSTALLED" = true ]; then
echo " journalctl -u trigger-claude-refresh -n 20 # Trigger logs"
echo " systemctl list-timers trigger-claude-refresh* # Check trigger timer"
fi
echo " ./scripts/verify.sh # Health check"
echo " ./setup.sh --uninstall # Remove everything"
echo ""

View File

@ -0,0 +1,10 @@
{
"anthropic": {
"access": "sk-ant-oat01-YOUR_ACCESS_TOKEN_HERE",
"refresh": "sk-ant-ort01-YOUR_REFRESH_TOKEN_HERE",
"expires": 1772120060006,
"scopes": ["user:inference", "user:mcp_servers", "user:profile", "user:sessions:claude_code"],
"subscriptionType": "max",
"rateLimitTier": "default_claude_max_5x"
}
}

View File

@ -0,0 +1,9 @@
[Unit]
Description=Refresh Claude OAuth Token (one-shot)
[Service]
Type=oneshot
ExecStart=@@REFRESH_SCRIPT_PATH@@
StandardOutput=journal
StandardError=journal
SyslogIdentifier=refresh-claude-token

View File

@ -0,0 +1,10 @@
[Unit]
Description=Refresh Claude OAuth Token every 6 hours
[Timer]
OnBootSec=5min
OnUnitActiveSec=6h
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -0,0 +1,16 @@
[Unit]
Description=Sync Claude OAuth Token to OpenClaw
After=docker.service
Requires=docker.service
[Service]
Type=simple
ExecStart=@@SYNC_SCRIPT_PATH@@
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=sync-oauth-token
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,13 @@
[Unit]
Description=Trigger Claude CLI OAuth token refresh
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
ExecStart=@@TRIGGER_SCRIPT_PATH@@
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,10 @@
[Unit]
Description=Trigger Claude CLI OAuth token refresh every 30 minutes
[Timer]
OnBootSec=5min
OnUnitActiveSec=30min
AccuracySec=1min
[Install]
WantedBy=timers.target

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."