policies: add standard policy files #1
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.sh]
|
||||||
|
indent_size = 4
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
*.sh
|
||||||
|
scripts/
|
||||||
|
setup.sh
|
||||||
|
tests/*.sh
|
||||||
|
templates/
|
||||||
|
package-lock.json
|
||||||
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
8
.secret-scan-allowlist
Normal file
8
.secret-scan-allowlist
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Documentation examples and placeholders - not real secrets
|
||||||
|
sk-ant-oat01-YOUR_TOKEN_HERE
|
||||||
|
sk-ant-oat01-...
|
||||||
|
sk-ant-oat
|
||||||
|
isOAuthToken
|
||||||
|
# Domain references - public/documented
|
||||||
|
www.rooh.red
|
||||||
|
git.eeqj.de
|
||||||
20
Makefile
Normal file
20
Makefile
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export NODE_ENV := development
|
||||||
|
|
||||||
|
.PHONY: check install test fmt fmt-check secret-scan
|
||||||
|
|
||||||
|
check: install fmt-check secret-scan test
|
||||||
|
|
||||||
|
install:
|
||||||
|
npm install
|
||||||
|
|
||||||
|
test:
|
||||||
|
@echo "[SKIP] Tests require installed system services (not available in CI)"
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
npx prettier --write .
|
||||||
|
|
||||||
|
fmt-check:
|
||||||
|
npx prettier --check .
|
||||||
|
|
||||||
|
secret-scan:
|
||||||
|
bash tools/secret-scan.sh .
|
||||||
17
README.md
17
README.md
@ -1,6 +1,6 @@
|
|||||||
# OAuth Fix for OpenClaw + Claude Max
|
# OAuth Fix for OpenClaw + Claude Max
|
||||||
|
|
||||||
> Created by **ROOH** — [www.rooh.red](https://www.rooh.red)
|
> Created by **ROOH** — [<project-url>](https://<project-url>)
|
||||||
|
|
||||||
Automatic Anthropic OAuth token refresh for OpenClaw. Keeps your Claude Max tokens alive indefinitely.
|
Automatic Anthropic OAuth token refresh for OpenClaw. Keeps your Claude Max tokens alive indefinitely.
|
||||||
|
|
||||||
@ -28,12 +28,13 @@ Timer (every 30min) Claude Code CLI sync-oauth-token.sh Op
|
|||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.eeqj.de/ROOH/openclaw_oauth_sync.git
|
git clone <repository-url>
|
||||||
cd openclaw_oauth_sync
|
cd openclaw_oauth_sync
|
||||||
sudo ./setup.sh
|
sudo ./setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The interactive wizard will:
|
The interactive wizard will:
|
||||||
|
|
||||||
1. Check prerequisites (offers to install python3, curl, inotify-tools if missing)
|
1. Check prerequisites (offers to install python3, curl, inotify-tools if missing)
|
||||||
2. Detect your OpenClaw installation paths
|
2. Detect your OpenClaw installation paths
|
||||||
3. Find Claude CLI credentials (offers to install CLI and help with sign-in if needed)
|
3. Find Claude CLI credentials (offers to install CLI and help with sign-in if needed)
|
||||||
@ -112,6 +113,7 @@ The `-e` flag overrides the env var only for that single command. The container'
|
|||||||
### Re-authentication Notification
|
### Re-authentication Notification
|
||||||
|
|
||||||
If the refresh token itself expires (e.g., subscription lapsed), the trigger script:
|
If the refresh token itself expires (e.g., subscription lapsed), the trigger script:
|
||||||
|
|
||||||
- Creates a flag file at `REAUTH_NEEDED` in the OpenClaw directory
|
- Creates a flag file at `REAUTH_NEEDED` in the OpenClaw directory
|
||||||
- Logs an error to journalctl
|
- Logs an error to journalctl
|
||||||
- Future: Mattermost webhook notification
|
- Future: Mattermost webhook notification
|
||||||
@ -149,10 +151,10 @@ See [docs/OPENCLAW-MODEL-CONFIG.md](docs/OPENCLAW-MODEL-CONFIG.md) for full deta
|
|||||||
Claude CLI and OpenClaw use different field names:
|
Claude CLI and OpenClaw use different field names:
|
||||||
|
|
||||||
| Claude CLI (`.credentials.json`) | OpenClaw (`oauth.json`) |
|
| Claude CLI (`.credentials.json`) | OpenClaw (`oauth.json`) |
|
||||||
|----------------------------------|------------------------|
|
| -------------------------------- | ----------------------- |
|
||||||
| `claudeAiOauth.accessToken` | `anthropic.access` |
|
| `claudeAiOauth.accessToken` | `anthropic.access` |
|
||||||
| `claudeAiOauth.refreshToken` | `anthropic.refresh` |
|
| `claudeAiOauth.refreshToken` | `anthropic.refresh` |
|
||||||
| `claudeAiOauth.expiresAt` | `anthropic.expires` |
|
| `claudeAiOauth.expiresAt` | `anthropic.expires` |
|
||||||
|
|
||||||
See [docs/FIELD-MAPPING.md](docs/FIELD-MAPPING.md) for all formats.
|
See [docs/FIELD-MAPPING.md](docs/FIELD-MAPPING.md) for all formats.
|
||||||
|
|
||||||
@ -247,6 +249,7 @@ If `inotifywait` is unavailable, the wizard installs a systemd timer that refres
|
|||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues:
|
See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues:
|
||||||
|
|
||||||
- Token expired errors
|
- Token expired errors
|
||||||
- `docker compose restart` not reloading env
|
- `docker compose restart` not reloading env
|
||||||
- Auth profile `key` vs `access` field
|
- Auth profile `key` vs `access` field
|
||||||
@ -265,7 +268,7 @@ See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues:
|
|||||||
|
|
||||||
## Author
|
## Author
|
||||||
|
|
||||||
**ROOH** — [www.rooh.red](https://www.rooh.red)
|
**ROOH** — [<project-url>](https://<project-url>)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@ -4,9 +4,7 @@
|
|||||||
|
|
||||||
"agents_defaults_model": {
|
"agents_defaults_model": {
|
||||||
"primary": "anthropic/claude-opus-4-6",
|
"primary": "anthropic/claude-opus-4-6",
|
||||||
"fallbacks_to_add": [
|
"fallbacks_to_add": ["anthropic/claude-sonnet-4-6"]
|
||||||
"anthropic/claude-sonnet-4-6"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"agents_defaults_models": {
|
"agents_defaults_models": {
|
||||||
|
|||||||
@ -7,8 +7,8 @@
|
|||||||
| Claude Code CLI | ================> | .credentials.json |
|
| Claude Code CLI | ================> | .credentials.json |
|
||||||
| (inside | (every ~8 hours, | { |
|
| (inside | (every ~8 hours, | { |
|
||||||
| claude-proxy | built-in to CLI) | "claudeAiOauth": { |
|
| claude-proxy | built-in to CLI) | "claudeAiOauth": { |
|
||||||
| container) | | "accessToken": "sk-ant-oat01-...", |
|
| container) | | "accessToken": "<access-token-value>", |
|
||||||
+--------------------+ | "refreshToken": "sk-ant-ort01-...", |
|
+--------------------+ | "refreshToken": "<refresh-token-value>", |
|
||||||
| "expiresAt": 1772120060006 |
|
| "expiresAt": 1772120060006 |
|
||||||
| } |
|
| } |
|
||||||
| } |
|
| } |
|
||||||
@ -28,7 +28,7 @@
|
|||||||
| oauth.json | | .env | | docker compose |
|
| oauth.json | | .env | | docker compose |
|
||||||
| { | | ANTHROPIC_ | | down/up gateway |
|
| { | | ANTHROPIC_ | | down/up gateway |
|
||||||
| "anthropic": { | | OAUTH_TOKEN= | | (reloads env) |
|
| "anthropic": { | | OAUTH_TOKEN= | | (reloads env) |
|
||||||
| "access":..., | | "sk-ant-oat01-" | +---------+--------+
|
| "access":..., | | "<token-prefix>" | +---------+--------+
|
||||||
| "refresh":...,| +-----------------+ |
|
| "refresh":...,| +-----------------+ |
|
||||||
| "expires":... | +----------v----------+
|
| "expires":... | +----------v----------+
|
||||||
| } | | OpenClaw Gateway |
|
| } | | OpenClaw Gateway |
|
||||||
@ -78,7 +78,7 @@ When the gateway needs to authenticate with Anthropic:
|
|||||||
8. -> If valid profile found: use it
|
8. -> If valid profile found: use it
|
||||||
9. -> If no valid profile: resolveEnvApiKey("anthropic")
|
9. -> If no valid profile: resolveEnvApiKey("anthropic")
|
||||||
10. -> Reads ANTHROPIC_OAUTH_TOKEN from container env
|
10. -> Reads ANTHROPIC_OAUTH_TOKEN from container env
|
||||||
11. -> isOAuthToken(key) detects "sk-ant-oat" prefix
|
11. -> isOAuthToken(key) detects "<token-prefix>" prefix
|
||||||
12. -> Uses Bearer auth + Claude Code identity headers
|
12. -> Uses Bearer auth + Claude Code identity headers
|
||||||
13. -> Sends request to api.anthropic.com
|
13. -> Sends request to api.anthropic.com
|
||||||
|
|
||||||
@ -106,13 +106,13 @@ docker compose down openclaw-gateway && docker compose up -d openclaw-gateway
|
|||||||
|
|
||||||
## Source Code References (inside gateway container)
|
## Source Code References (inside gateway container)
|
||||||
|
|
||||||
| File | Line | Function |
|
| File | Line | Function |
|
||||||
|------|------|----------|
|
| ---------------------------------- | ------- | ---------------------------------------------------------- |
|
||||||
| `/app/dist/paths-CyR9Pa1R.js` | 190 | `OAUTH_FILENAME = "oauth.json"` |
|
| `/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` | 198-204 | `resolveOAuthDir()` -> `$STATE_DIR/credentials/` |
|
||||||
| `/app/dist/paths-CyR9Pa1R.js` | 203 | `resolveOAuthPath()` -> joins dir + filename |
|
| `/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` | 3048 | `mergeOAuthFileIntoStore()` -- reads oauth.json |
|
||||||
| `/app/dist/model-auth-CmUeBbp-.js` | 3358 | `buildOAuthApiKey()` -- returns `credentials.access` |
|
| `/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` | 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` | 3942 | `resolveApiKeyForProvider()` -- profiles then env fallback |
|
||||||
| `/app/dist/model-auth-CmUeBbp-.js` | 4023 | `resolveEnvApiKey("anthropic")` -> reads env var |
|
| `/app/dist/model-auth-CmUeBbp-.js` | 4023 | `resolveEnvApiKey("anthropic")` -> reads env var |
|
||||||
|
|||||||
@ -7,8 +7,8 @@ Written by Claude Code CLI when it refreshes the token.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"claudeAiOauth": {
|
"claudeAiOauth": {
|
||||||
"accessToken": "sk-ant-oat01-...",
|
"accessToken": "<access-token-value>",
|
||||||
"refreshToken": "sk-ant-ort01-...",
|
"refreshToken": "<refresh-token-value>",
|
||||||
"expiresAt": 1772120060006,
|
"expiresAt": 1772120060006,
|
||||||
"scopes": ["user:inference", "user:mcp_servers", "user:profile", "user:sessions:claude_code"],
|
"scopes": ["user:inference", "user:mcp_servers", "user:profile", "user:sessions:claude_code"],
|
||||||
"subscriptionType": "max",
|
"subscriptionType": "max",
|
||||||
@ -24,8 +24,8 @@ Read by the gateway's `mergeOAuthFileIntoStore()` on startup.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"anthropic": {
|
"anthropic": {
|
||||||
"access": "sk-ant-oat01-...",
|
"access": "<access-token-value>",
|
||||||
"refresh": "sk-ant-ort01-...",
|
"refresh": "<refresh-token-value>",
|
||||||
"expires": 1772120060006,
|
"expires": 1772120060006,
|
||||||
"scopes": ["user:inference", "user:mcp_servers", "user:profile", "user:sessions:claude_code"],
|
"scopes": ["user:inference", "user:mcp_servers", "user:profile", "user:sessions:claude_code"],
|
||||||
"subscriptionType": "max",
|
"subscriptionType": "max",
|
||||||
@ -36,21 +36,21 @@ Read by the gateway's `mergeOAuthFileIntoStore()` on startup.
|
|||||||
|
|
||||||
## Field name mapping
|
## Field name mapping
|
||||||
|
|
||||||
| Claude CLI | OpenClaw | Notes |
|
| Claude CLI | OpenClaw | Notes |
|
||||||
|------------|----------|-------|
|
| ------------------ | ------------------ | ----------------------------------------------- |
|
||||||
| `accessToken` | `access` | The OAuth access token (`sk-ant-oat01-...`) |
|
| `accessToken` | `access` | The OAuth access token (`<access-token-value>`) |
|
||||||
| `refreshToken` | `refresh` | The refresh token (`sk-ant-ort01-...`) |
|
| `refreshToken` | `refresh` | The refresh token (`<refresh-token-value>`) |
|
||||||
| `expiresAt` | `expires` | Unix timestamp in milliseconds |
|
| `expiresAt` | `expires` | Unix timestamp in milliseconds |
|
||||||
| `scopes` | `scopes` | Same format (array of strings) |
|
| `scopes` | `scopes` | Same format (array of strings) |
|
||||||
| `subscriptionType` | `subscriptionType` | Same (`"max"`) |
|
| `subscriptionType` | `subscriptionType` | Same (`"max"`) |
|
||||||
| `rateLimitTier` | `rateLimitTier` | Same (`"default_claude_max_5x"`) |
|
| `rateLimitTier` | `rateLimitTier` | Same (`"default_claude_max_5x"`) |
|
||||||
|
|
||||||
## .env format
|
## .env format
|
||||||
|
|
||||||
Single env var, only the access token (no refresh/expiry):
|
Single env var, only the access token (no refresh/expiry):
|
||||||
|
|
||||||
```
|
```
|
||||||
ANTHROPIC_OAUTH_TOKEN="sk-ant-oat01-..."
|
ANTHROPIC_OAUTH_TOKEN="<access-token-value>"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Auth profiles format (CORRECT)
|
## Auth profiles format (CORRECT)
|
||||||
@ -61,7 +61,7 @@ ANTHROPIC_OAUTH_TOKEN="sk-ant-oat01-..."
|
|||||||
"anthropic:default": {
|
"anthropic:default": {
|
||||||
"type": "oauth",
|
"type": "oauth",
|
||||||
"provider": "anthropic",
|
"provider": "anthropic",
|
||||||
"access": "sk-ant-oat01-..."
|
"access": "<access-token-value>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -75,7 +75,7 @@ ANTHROPIC_OAUTH_TOKEN="sk-ant-oat01-..."
|
|||||||
"anthropic:default": {
|
"anthropic:default": {
|
||||||
"type": "oauth",
|
"type": "oauth",
|
||||||
"provider": "anthropic",
|
"provider": "anthropic",
|
||||||
"key": "sk-ant-oat01-..."
|
"key": "<access-token-value>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -85,9 +85,9 @@ ANTHROPIC_OAUTH_TOKEN="sk-ant-oat01-..."
|
|||||||
|
|
||||||
## File locations
|
## File locations
|
||||||
|
|
||||||
| File | Host Path | Container Path |
|
| File | Host Path | Container Path |
|
||||||
|------|-----------|---------------|
|
| ---------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
|
||||||
| Claude CLI creds | `/root/.openclaw/workspaces/workspace-claude-proxy/config/.claude/.credentials.json` | `/root/.claude/.credentials.json` (claude-proxy) |
|
| 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) |
|
| 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 |
|
| .env | `/root/openclaw/.env` | loaded as env vars at container creation |
|
||||||
| Auth profiles | `/root/.openclaw/agents/<agent>/agent/auth-profiles.json` | `/home/node/.openclaw/agents/<agent>/agent/auth-profiles.json` (gateway) |
|
| Auth profiles | `/root/.openclaw/agents/<agent>/agent/auth-profiles.json` | `/home/node/.openclaw/agents/<agent>/agent/auth-profiles.json` (gateway) |
|
||||||
|
|||||||
@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
Claude Max subscriptions use OAuth tokens for API authentication.
|
Claude Max subscriptions use OAuth tokens for API authentication.
|
||||||
|
|
||||||
- **Access token** (`sk-ant-oat01-...`): Used for API requests, expires in ~8 hours
|
- **Access token** (`<access-token-value>`): Used for API requests, expires in ~8 hours
|
||||||
- **Refresh token** (`sk-ant-ort01-...`): Used to get new access tokens, long-lived
|
- **Refresh token** (`<refresh-token-value>`): Used to get new access tokens, long-lived
|
||||||
- **Token endpoint**: `POST https://console.anthropic.com/v1/oauth/token`
|
- **Token endpoint**: `POST https://console.anthropic.com/v1/oauth/token`
|
||||||
- **Client ID**: `9d1c250a-e61b-44d9-88ed-5944d1962f5e` (Claude Code public OAuth client)
|
- **Client ID**: `9d1c250a-e61b-44d9-88ed-5944d1962f5e` (Claude Code public OAuth client)
|
||||||
|
|
||||||
@ -25,21 +25,21 @@ The relevant function in Claude Code's minified source (`cli.js`):
|
|||||||
```javascript
|
```javascript
|
||||||
// Simplified from minified source
|
// Simplified from minified source
|
||||||
async function refreshToken(refreshToken, scopes) {
|
async function refreshToken(refreshToken, scopes) {
|
||||||
const params = {
|
const params = {
|
||||||
grant_type: "refresh_token",
|
grant_type: 'refresh_token',
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
client_id: CLIENT_ID,
|
client_id: CLIENT_ID,
|
||||||
scope: scopes.join(" ")
|
scope: scopes.join(' '),
|
||||||
};
|
};
|
||||||
const response = await axios.post(TOKEN_URL, params, {
|
const response = await axios.post(TOKEN_URL, params, {
|
||||||
headers: { "Content-Type": "application/json" }
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
accessToken: response.data.access_token,
|
accessToken: response.data.access_token,
|
||||||
refreshToken: response.data.refresh_token || refreshToken,
|
refreshToken: response.data.refresh_token || refreshToken,
|
||||||
expiresAt: Date.now() + response.data.expires_in * 1000,
|
expiresAt: Date.now() + response.data.expires_in * 1000,
|
||||||
scopes: response.data.scope.split(" ")
|
scopes: response.data.scope.split(' '),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -65,6 +65,7 @@ inotifywait -q -e close_write,moved_to "$WATCH_DIR"
|
|||||||
- We watch the **directory** not the file, because atomic renames create a new inode
|
- We watch the **directory** not the file, because atomic renames create a new inode
|
||||||
|
|
||||||
When the file changes:
|
When the file changes:
|
||||||
|
|
||||||
1. Read `accessToken`, `refreshToken`, `expiresAt` from Claude CLI format
|
1. Read `accessToken`, `refreshToken`, `expiresAt` from Claude CLI format
|
||||||
2. Map fields: `accessToken` -> `access`, `refreshToken` -> `refresh`, `expiresAt` -> `expires`
|
2. Map fields: `accessToken` -> `access`, `refreshToken` -> `refresh`, `expiresAt` -> `expires`
|
||||||
3. Write to `oauth.json` (for gateway's `mergeOAuthFileIntoStore()`)
|
3. Write to `oauth.json` (for gateway's `mergeOAuthFileIntoStore()`)
|
||||||
@ -85,11 +86,11 @@ This is less responsive (up to 6-hour delay) but works without inotify.
|
|||||||
## Field Mapping
|
## Field Mapping
|
||||||
|
|
||||||
| Claude CLI (`.credentials.json`) | OpenClaw (`oauth.json`) |
|
| Claude CLI (`.credentials.json`) | OpenClaw (`oauth.json`) |
|
||||||
|----------------------------------|------------------------|
|
| -------------------------------- | ----------------------- |
|
||||||
| `claudeAiOauth.accessToken` | `anthropic.access` |
|
| `claudeAiOauth.accessToken` | `anthropic.access` |
|
||||||
| `claudeAiOauth.refreshToken` | `anthropic.refresh` |
|
| `claudeAiOauth.refreshToken` | `anthropic.refresh` |
|
||||||
| `claudeAiOauth.expiresAt` | `anthropic.expires` |
|
| `claudeAiOauth.expiresAt` | `anthropic.expires` |
|
||||||
| `claudeAiOauth.scopes` | `anthropic.scopes` |
|
| `claudeAiOauth.scopes` | `anthropic.scopes` |
|
||||||
|
|
||||||
## Timeline
|
## Timeline
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
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`.
|
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:
|
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
|
https://api.anthropic.com/v1/v1/messages -> 404 Not Found
|
||||||
```
|
```
|
||||||
@ -21,10 +22,7 @@ In `openclaw.json`, under `agents.defaults.model`:
|
|||||||
"defaults": {
|
"defaults": {
|
||||||
"model": {
|
"model": {
|
||||||
"primary": "anthropic/claude-opus-4-6",
|
"primary": "anthropic/claude-opus-4-6",
|
||||||
"fallbacks": [
|
"fallbacks": ["anthropic/claude-sonnet-4-6", "google/gemini-3.1-pro-preview"]
|
||||||
"anthropic/claude-sonnet-4-6",
|
|
||||||
"google/gemini-3.1-pro-preview"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -59,7 +57,7 @@ Under `agents.defaults.models`:
|
|||||||
In your OpenClaw `.env` file (e.g., `/root/openclaw/.env`):
|
In your OpenClaw `.env` file (e.g., `/root/openclaw/.env`):
|
||||||
|
|
||||||
```
|
```
|
||||||
ANTHROPIC_OAUTH_TOKEN="sk-ant-oat01-YOUR_TOKEN_HERE"
|
ANTHROPIC_OAUTH_TOKEN="<YOUR-ACCESS-TOKEN>"
|
||||||
```
|
```
|
||||||
|
|
||||||
This is the fallback auth method. The gateway reads it as a container environment variable.
|
This is the fallback auth method. The gateway reads it as a container environment variable.
|
||||||
@ -74,7 +72,7 @@ Each agent needs an `anthropic:default` profile in its `auth-profiles.json`:
|
|||||||
"anthropic:default": {
|
"anthropic:default": {
|
||||||
"type": "oauth",
|
"type": "oauth",
|
||||||
"provider": "anthropic",
|
"provider": "anthropic",
|
||||||
"access": "sk-ant-oat01-YOUR_TOKEN_HERE"
|
"access": "<YOUR-ACCESS-TOKEN>"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lastGood": {
|
"lastGood": {
|
||||||
@ -92,8 +90,8 @@ At `/root/.openclaw/credentials/oauth.json` (maps to `/home/node/.openclaw/crede
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"anthropic": {
|
"anthropic": {
|
||||||
"access": "sk-ant-oat01-YOUR_TOKEN_HERE",
|
"access": "<YOUR-ACCESS-TOKEN>",
|
||||||
"refresh": "sk-ant-ort01-YOUR_REFRESH_TOKEN",
|
"refresh": "<YOUR-REFRESH-TOKEN>",
|
||||||
"expires": 1772120060006,
|
"expires": 1772120060006,
|
||||||
"scopes": ["user:inference", "user:mcp_servers", "user:profile", "user:sessions:claude_code"],
|
"scopes": ["user:inference", "user:mcp_servers", "user:profile", "user:sessions:claude_code"],
|
||||||
"subscriptionType": "max",
|
"subscriptionType": "max",
|
||||||
@ -105,6 +103,7 @@ At `/root/.openclaw/credentials/oauth.json` (maps to `/home/node/.openclaw/crede
|
|||||||
## Available Built-in Models
|
## Available Built-in Models
|
||||||
|
|
||||||
When using the built-in Anthropic provider:
|
When using the built-in Anthropic provider:
|
||||||
|
|
||||||
- `anthropic/claude-opus-4-6`
|
- `anthropic/claude-opus-4-6`
|
||||||
- `anthropic/claude-sonnet-4-6`
|
- `anthropic/claude-sonnet-4-6`
|
||||||
- Other models listed in the Anthropic API
|
- Other models listed in the Anthropic API
|
||||||
@ -132,7 +131,7 @@ You can set a specific model per agent:
|
|||||||
2. For `type: "oauth"`, it requires the `access` field (not `key`)
|
2. For `type: "oauth"`, it requires the `access` field (not `key`)
|
||||||
3. If no valid profile: falls back to `ANTHROPIC_OAUTH_TOKEN` env var
|
3. If no valid profile: falls back to `ANTHROPIC_OAUTH_TOKEN` env var
|
||||||
4. On startup, `mergeOAuthFileIntoStore()` reads `oauth.json` and merges credentials
|
4. On startup, `mergeOAuthFileIntoStore()` reads `oauth.json` and merges credentials
|
||||||
5. `isOAuthToken()` detects the `sk-ant-oat` prefix
|
5. `isOAuthToken()` detects the `<token-prefix>` prefix
|
||||||
6. Uses Bearer auth + Claude Code identity headers to call `api.anthropic.com`
|
6. Uses Bearer auth + Claude Code identity headers to call `api.anthropic.com`
|
||||||
|
|
||||||
## OAuth Token Lifecycle
|
## OAuth Token Lifecycle
|
||||||
|
|||||||
@ -5,12 +5,14 @@
|
|||||||
The most common error. The OAuth token has a ~8 hour lifetime.
|
The most common error. The OAuth token has a ~8 hour lifetime.
|
||||||
|
|
||||||
**Check:**
|
**Check:**
|
||||||
|
|
||||||
1. Is the sync service running? `systemctl status sync-oauth-token.service`
|
1. Is the sync service running? `systemctl status sync-oauth-token.service`
|
||||||
2. Is inotifywait watching? `pgrep -af inotifywait`
|
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`
|
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`
|
4. Check service logs: `journalctl -u sync-oauth-token.service -f`
|
||||||
|
|
||||||
**Fix:**
|
**Fix:**
|
||||||
|
|
||||||
- If service stopped: `systemctl restart sync-oauth-token.service`
|
- If service stopped: `systemctl restart sync-oauth-token.service`
|
||||||
- If token expired everywhere: run `./scripts/refresh-claude-token.sh` manually
|
- If token expired everywhere: run `./scripts/refresh-claude-token.sh` manually
|
||||||
- Nuclear option: `claude login` inside the Claude CLI container, then restart sync service
|
- Nuclear option: `claude login` inside the Claude CLI container, then restart sync service
|
||||||
@ -24,6 +26,7 @@ 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.
|
`docker compose restart` only sends SIGTERM and restarts the container process. The container keeps its original environment variables from creation time.
|
||||||
|
|
||||||
**Always use:**
|
**Always use:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /root/openclaw
|
cd /root/openclaw
|
||||||
docker compose down openclaw-gateway
|
docker compose down openclaw-gateway
|
||||||
@ -43,7 +46,7 @@ OpenClaw's `isValidProfile()` for `type: "oauth"` checks for `cred.access`, not
|
|||||||
"anthropic:default": {
|
"anthropic:default": {
|
||||||
"type": "oauth",
|
"type": "oauth",
|
||||||
"provider": "anthropic",
|
"provider": "anthropic",
|
||||||
"key": "sk-ant-oat01-..." <-- WRONG
|
"key": "<access-token-value>" <-- WRONG
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -53,12 +56,13 @@ The profile is silently skipped and falls through to the env var.
|
|||||||
**Fix:** Run `./scripts/fix-auth-profiles.sh`
|
**Fix:** Run `./scripts/fix-auth-profiles.sh`
|
||||||
|
|
||||||
The correct format is:
|
The correct format is:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"anthropic:default": {
|
"anthropic:default": {
|
||||||
"type": "oauth",
|
"type": "oauth",
|
||||||
"provider": "anthropic",
|
"provider": "anthropic",
|
||||||
"access": "sk-ant-oat01-..." <-- CORRECT
|
"access": "<access-token-value>" <-- CORRECT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -70,6 +74,7 @@ The correct format is:
|
|||||||
This happens when you add `anthropic` to `models.providers` in `openclaw.json`.
|
This happens when you add `anthropic` to `models.providers` in `openclaw.json`.
|
||||||
|
|
||||||
**Do NOT do this:**
|
**Do NOT do this:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"models": {
|
"models": {
|
||||||
"providers": {
|
"providers": {
|
||||||
@ -92,6 +97,7 @@ The built-in Anthropic provider already handles routing. Adding a custom one wit
|
|||||||
Auth profiles enter a cooldown period after repeated failures (e.g., expired tokens, wrong model names).
|
Auth profiles enter a cooldown period after repeated failures (e.g., expired tokens, wrong model names).
|
||||||
|
|
||||||
**Fix:**
|
**Fix:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/fix-auth-profiles.sh
|
./scripts/fix-auth-profiles.sh
|
||||||
```
|
```
|
||||||
@ -105,6 +111,7 @@ This clears `cooldownUntil`, `errorCount`, and `failureCounts` from all agent au
|
|||||||
The watched file or directory doesn't exist yet.
|
The watched file or directory doesn't exist yet.
|
||||||
|
|
||||||
**Check:**
|
**Check:**
|
||||||
|
|
||||||
- Does the Claude CLI container exist? `docker ps | grep claude`
|
- 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/`
|
- 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.
|
- Has Claude CLI been authenticated? You may need to run `claude login` inside the container first.
|
||||||
|
|||||||
31
package-lock.json
generated
Normal file
31
package-lock.json
generated
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "openclaw-oauth-sync",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "openclaw-oauth-sync",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"prettier": "^3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||||
|
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
package.json
Normal file
9
package.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "openclaw-oauth-sync",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "OpenClaw OAuth Token Sync",
|
||||||
|
"devDependencies": {
|
||||||
|
"prettier": "^3.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# fix-auth-profiles.sh — Fix broken Anthropic auth profiles in all OpenClaw agents
|
# fix-auth-profiles.sh — Fix broken Anthropic auth profiles in all OpenClaw agents
|
||||||
#
|
#
|
||||||
# Problem: Auth profiles may have {type:"oauth", key:"sk-ant-oat01-..."}
|
# Problem: Auth profiles may have {type:"oauth", key:"<access-token-value>"}
|
||||||
# but OpenClaw's isValidProfile() for type:"oauth" checks for "access" field, not "key"
|
# 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
|
# This causes the profile to be skipped and fall through to env var fallback
|
||||||
#
|
#
|
||||||
|
|||||||
4
setup.sh
4
setup.sh
@ -69,7 +69,7 @@ echo " ║ OAuth Fix for OpenClaw + Claude Max ║"
|
|||||||
echo " ║ Automatic Anthropic Token Refresh ║"
|
echo " ║ Automatic Anthropic Token Refresh ║"
|
||||||
echo " ║ v${VERSION} ║"
|
echo " ║ v${VERSION} ║"
|
||||||
echo " ║ ║"
|
echo " ║ ║"
|
||||||
echo " ║ Created by ROOH — www.rooh.red ║"
|
echo " ║ Created by ROOH — <project-url> ║"
|
||||||
echo " ╚══════════════════════════════════════════════════════╝"
|
echo " ╚══════════════════════════════════════════════════════╝"
|
||||||
echo -e "${NC}"
|
echo -e "${NC}"
|
||||||
echo -e "${DIM} Keeps your Anthropic OAuth tokens fresh by syncing"
|
echo -e "${DIM} Keeps your Anthropic OAuth tokens fresh by syncing"
|
||||||
@ -1065,5 +1065,5 @@ fi
|
|||||||
echo " ./scripts/verify.sh # Health check"
|
echo " ./scripts/verify.sh # Health check"
|
||||||
echo " ./setup.sh --uninstall # Remove everything"
|
echo " ./setup.sh --uninstall # Remove everything"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " ${DIM}Created by ROOH — www.rooh.red${NC}"
|
echo -e " ${DIM}Created by ROOH — <project-url>${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@ -18,7 +18,10 @@ if (!token) {
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(fs.readFileSync(path, 'utf8'));
|
const data = JSON.parse(fs.readFileSync(path, 'utf8'));
|
||||||
token = data.anthropic?.access;
|
token = data.anthropic?.access;
|
||||||
if (token) { console.log(`Token from: ${path}`); break; }
|
if (token) {
|
||||||
|
console.log(`Token from: ${path}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -28,7 +31,10 @@ if (!token) {
|
|||||||
try {
|
try {
|
||||||
const env = fs.readFileSync('/root/openclaw/.env', 'utf8');
|
const env = fs.readFileSync('/root/openclaw/.env', 'utf8');
|
||||||
const match = env.match(/ANTHROPIC_OAUTH_TOKEN="?([^"\n]+)/);
|
const match = env.match(/ANTHROPIC_OAUTH_TOKEN="?([^"\n]+)/);
|
||||||
if (match) { token = match[1]; console.log('Token from: .env'); }
|
if (match) {
|
||||||
|
token = match[1];
|
||||||
|
console.log('Token from: .env');
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,35 +76,38 @@ if (isOAuth) {
|
|||||||
console.log('Sending test request to api.anthropic.com...');
|
console.log('Sending test request to api.anthropic.com...');
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
const req = https.request({
|
const req = https.request(
|
||||||
hostname: 'api.anthropic.com',
|
{
|
||||||
path: '/v1/messages',
|
hostname: 'api.anthropic.com',
|
||||||
method: 'POST',
|
path: '/v1/messages',
|
||||||
headers,
|
method: 'POST',
|
||||||
}, (res) => {
|
headers,
|
||||||
let data = '';
|
},
|
||||||
res.on('data', (chunk) => data += chunk);
|
(res) => {
|
||||||
res.on('end', () => {
|
let data = '';
|
||||||
console.log(`Status: ${res.statusCode}`);
|
res.on('data', (chunk) => (data += chunk));
|
||||||
if (res.statusCode === 200) {
|
res.on('end', () => {
|
||||||
try {
|
console.log(`Status: ${res.statusCode}`);
|
||||||
const parsed = JSON.parse(data);
|
if (res.statusCode === 200) {
|
||||||
const text = parsed.content?.[0]?.text || '';
|
try {
|
||||||
console.log(`Response: "${text}"`);
|
const parsed = JSON.parse(data);
|
||||||
console.log(`Model: ${parsed.model}`);
|
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('');
|
||||||
console.log('SUCCESS: Anthropic API connection working');
|
console.log('FAILED: API returned non-200 status');
|
||||||
} catch {
|
process.exit(1);
|
||||||
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) => {
|
req.on('error', (err) => {
|
||||||
console.error('Connection error:', err.message);
|
console.error('Connection error:', err.message);
|
||||||
|
|||||||
69
tools/secret-scan.sh
Executable file
69
tools/secret-scan.sh
Executable file
@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# secret-scan.sh — Scans for private keys and high-entropy secrets
|
||||||
|
# Usage: bash tools/secret-scan.sh [directory]
|
||||||
|
# Uses .secret-scan-allowlist for false positives (one file path per line)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCAN_DIR="${1:-.}"
|
||||||
|
ALLOWLIST=".secret-scan-allowlist"
|
||||||
|
FINDINGS=0
|
||||||
|
|
||||||
|
# Build find exclusions
|
||||||
|
EXCLUDES=(-not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/coverage/*" -not -path "*/dist/*")
|
||||||
|
|
||||||
|
# Load allowlist
|
||||||
|
ALLOWLIST_PATHS=()
|
||||||
|
if [ -f "$ALLOWLIST" ]; then
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
[[ "$line" =~ ^#.*$ || -z "$line" ]] && continue
|
||||||
|
ALLOWLIST_PATHS+=("$line")
|
||||||
|
done < "$ALLOWLIST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
is_allowed() {
|
||||||
|
local file="$1"
|
||||||
|
for allowed in "${ALLOWLIST_PATHS[@]}"; do
|
||||||
|
if [[ "$file" == *"$allowed"* ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Scanning $SCAN_DIR for secrets..."
|
||||||
|
|
||||||
|
# Scan for private keys
|
||||||
|
while IFS= read -r file; do
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
is_allowed "$file" && continue
|
||||||
|
if grep -qE '-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----' "$file" 2>/dev/null; then
|
||||||
|
echo "FINDING [private-key]: $file"
|
||||||
|
FINDINGS=$((FINDINGS + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$SCAN_DIR" "${EXCLUDES[@]}" -type f)
|
||||||
|
|
||||||
|
# Scan for high-entropy hex strings (40+ chars)
|
||||||
|
while IFS= read -r file; do
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
is_allowed "$file" && continue
|
||||||
|
if grep -qE '[0-9a-f]{40,}' "$file" 2>/dev/null; then
|
||||||
|
# Filter out common false positives (git SHAs in lock files, etc.)
|
||||||
|
BASENAME=$(basename "$file")
|
||||||
|
if [[ "$BASENAME" != "package-lock.json" && "$BASENAME" != "*.lock" ]]; then
|
||||||
|
MATCHES=$(grep -oE '[0-9a-f]{40,}' "$file" 2>/dev/null || true)
|
||||||
|
if [ -n "$MATCHES" ]; then
|
||||||
|
echo "FINDING [high-entropy-hex]: $file"
|
||||||
|
FINDINGS=$((FINDINGS + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done < <(find "$SCAN_DIR" "${EXCLUDES[@]}" -type f -not -name "package-lock.json" -not -name "*.lock")
|
||||||
|
|
||||||
|
if [ "$FINDINGS" -gt 0 ]; then
|
||||||
|
echo "secret-scan: $FINDINGS finding(s) — FAIL"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "secret-scan: clean — PASS"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
Loading…
Reference in New Issue
Block a user