- 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>
108 lines
4.1 KiB
Markdown
108 lines
4.1 KiB
Markdown
# 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.
|