diff --git a/LICENSE b/LICENSE index 5c90c3e..14fac91 100644 --- a/LICENSE +++ b/LICENSE @@ -1,9 +1,21 @@ 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. diff --git a/README.md b/README.md index cbb76bf..ea4d349 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file +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 diff --git a/configs/openclaw-anthropic.json b/configs/openclaw-anthropic.json new file mode 100644 index 0000000..2d8c557 --- /dev/null +++ b/configs/openclaw-anthropic.json @@ -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@@" + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..3ae2738 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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/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 | diff --git a/docs/FIELD-MAPPING.md b/docs/FIELD-MAPPING.md new file mode 100644 index 0000000..81267d0 --- /dev/null +++ b/docs/FIELD-MAPPING.md @@ -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/auth-profiles.json` | `/home/node/.openclaw/agents//agent/auth-profiles.json` (gateway) | diff --git a/docs/HOW-TOKEN-REFRESH-WORKS.md b/docs/HOW-TOKEN-REFRESH-WORKS.md new file mode 100644 index 0000000..649eae9 --- /dev/null +++ b/docs/HOW-TOKEN-REFRESH-WORKS.md @@ -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. diff --git a/docs/OPENCLAW-MODEL-CONFIG.md b/docs/OPENCLAW-MODEL-CONFIG.md new file mode 100644 index 0000000..7fdef71 --- /dev/null +++ b/docs/OPENCLAW-MODEL-CONFIG.md @@ -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. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..4203eec --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -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` diff --git a/scripts/fix-auth-profiles.sh b/scripts/fix-auth-profiles.sh new file mode 100755 index 0000000..ab5bd48 --- /dev/null +++ b/scripts/fix-auth-profiles.sh @@ -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" diff --git a/scripts/refresh-claude-token.sh b/scripts/refresh-claude-token.sh new file mode 100755 index 0000000..046aa9b --- /dev/null +++ b/scripts/refresh-claude-token.sh @@ -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" diff --git a/scripts/sync-oauth-token.sh b/scripts/sync-oauth-token.sh new file mode 100755 index 0000000..66b5864 --- /dev/null +++ b/scripts/sync-oauth-token.sh @@ -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 diff --git a/scripts/trigger-claude-refresh.sh b/scripts/trigger-claude-refresh.sh new file mode 100755 index 0000000..5532d1f --- /dev/null +++ b/scripts/trigger-claude-refresh.sh @@ -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 diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..5d05de1 --- /dev/null +++ b/scripts/uninstall.sh @@ -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." diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 0000000..a9fc145 --- /dev/null +++ b/scripts/verify.sh @@ -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 diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..0c50325 --- /dev/null +++ b/setup.sh @@ -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 "" diff --git a/templates/oauth.json.template b/templates/oauth.json.template new file mode 100644 index 0000000..0a55144 --- /dev/null +++ b/templates/oauth.json.template @@ -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" + } +} diff --git a/templates/refresh-claude-token.service b/templates/refresh-claude-token.service new file mode 100644 index 0000000..732e61f --- /dev/null +++ b/templates/refresh-claude-token.service @@ -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 diff --git a/templates/refresh-claude-token.timer b/templates/refresh-claude-token.timer new file mode 100644 index 0000000..22a5505 --- /dev/null +++ b/templates/refresh-claude-token.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Refresh Claude OAuth Token every 6 hours + +[Timer] +OnBootSec=5min +OnUnitActiveSec=6h +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/templates/sync-oauth-token.service b/templates/sync-oauth-token.service new file mode 100644 index 0000000..2559083 --- /dev/null +++ b/templates/sync-oauth-token.service @@ -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 diff --git a/templates/trigger-claude-refresh.service b/templates/trigger-claude-refresh.service new file mode 100644 index 0000000..2c240ec --- /dev/null +++ b/templates/trigger-claude-refresh.service @@ -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 diff --git a/templates/trigger-claude-refresh.timer b/templates/trigger-claude-refresh.timer new file mode 100644 index 0000000..c19e782 --- /dev/null +++ b/templates/trigger-claude-refresh.timer @@ -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 diff --git a/tests/test-anthropic-connection.mjs b/tests/test-anthropic-connection.mjs new file mode 100644 index 0000000..139d1b1 --- /dev/null +++ b/tests/test-anthropic-connection.mjs @@ -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(); diff --git a/tests/test-sync-flow.sh b/tests/test-sync-flow.sh new file mode 100755 index 0000000..a83db1b --- /dev/null +++ b/tests/test-sync-flow.sh @@ -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." diff --git a/tests/test-token-refresh.sh b/tests/test-token-refresh.sh new file mode 100755 index 0000000..0725f2b --- /dev/null +++ b/tests/test-token-refresh.sh @@ -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."