feat(models): add oauth auth health

This commit is contained in:
Peter Steinberger
2026-01-09 00:32:48 +00:00
parent bcec534e5e
commit 948ce5eb5f
19 changed files with 862 additions and 179 deletions

View File

@@ -2,6 +2,7 @@
## Unreleased ## Unreleased
- Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223
- Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles. - Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles.
- Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence. - Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence.
- Agent: enable adaptive context pruning by default for tool-result trimming. - Agent: enable adaptive context pruning by default for tool-result trimming.

View File

@@ -0,0 +1,41 @@
---
summary: "Monitor OAuth expiry for model providers"
read_when:
- Setting up auth expiry monitoring or alerts
- Automating Claude Code / Codex OAuth refresh checks
---
# Auth monitoring
Clawdbot exposes OAuth expiry health via `clawdbot models status`. Use that for
automation and alerting; scripts are optional extras for phone workflows.
## Preferred: CLI check (portable)
```bash
clawdbot models status --check
```
Exit codes:
- `0`: OK
- `1`: expired or missing credentials
- `2`: expiring soon (within 24h)
This works in cron/systemd and requires no extra scripts.
## Optional scripts (ops / phone workflows)
These live under `scripts/` and are **optional**. They assume SSH access to the
gateway host and are tuned for systemd + Termux.
- `scripts/claude-auth-status.sh` now uses `clawdbot models status --json` as the
source of truth (falling back to direct file reads if the CLI is unavailable),
so keep `clawdbot` on `PATH` for timers.
- `scripts/auth-monitor.sh`: cron/systemd timer target; sends alerts (ntfy or phone).
- `scripts/systemd/clawdbot-auth-monitor.{service,timer}`: systemd user timer.
- `scripts/claude-auth-status.sh`: Claude Code + Clawdbot auth checker (full/json/simple).
- `scripts/mobile-reauth.sh`: guided reauth flow over SSH.
- `scripts/termux-quick-auth.sh`: onetap widget status + open auth URL.
- `scripts/termux-auth-widget.sh`: full guided widget flow.
- `scripts/termux-sync-widget.sh`: sync Claude Code creds → Clawdbot.
If you dont need phone automation or systemd timers, skip these scripts.

View File

@@ -479,6 +479,9 @@ Options:
Options: Options:
- `--json` - `--json`
- `--plain` - `--plain`
- `--check` (exit 1=expired/missing, 2=expiring)
Always includes the auth overview and OAuth expiry status for profiles in the auth store.
### `models set <model>` ### `models set <model>`
Set `agent.model.primary`. Set `agent.model.primary`.

View File

@@ -71,7 +71,14 @@ Shows configured models by default. Useful flags:
### `models status` ### `models status`
Shows the resolved primary model, fallbacks, image model, and an auth overview Shows the resolved primary model, fallbacks, image model, and an auth overview
of configured providers. `--plain` prints only the resolved primary model. of configured providers. It also surfaces OAuth expiry status for profiles found
in the auth store (warns within 24h by default). `--plain` prints only the
resolved primary model.
OAuth status is always shown (and included in `--json` output). If a configured
provider has no credentials, `models status` prints a **Missing auth** section.
JSON includes `auth.oauth` (warn window + profiles) and `auth.providers`
(effective auth per provider).
Use `--check` for automation (exit `1` when missing/expired, `2` when expiring).
## Scanning (OpenRouter free models) ## Scanning (OpenRouter free models)

View File

@@ -49,10 +49,13 @@ If you already signed in with the external CLIs *on the gateway host*, Clawdbot
- Codex CLI: reads `~/.codex/auth.json` → profile `openai-codex:codex-cli` - Codex CLI: reads `~/.codex/auth.json` → profile `openai-codex:codex-cli`
Sync happens when Clawdbot loads the auth store (so it stays up-to-date when the CLIs refresh tokens). Sync happens when Clawdbot loads the auth store (so it stays up-to-date when the CLIs refresh tokens).
On macOS, the first read may trigger a Keychain prompt; run `clawdbot models status`
in a terminal once if the Gateway runs headless and cant access the entry.
How to verify: How to verify:
```bash ```bash
clawdbot models status
clawdbot providers list clawdbot providers list
``` ```

View File

@@ -97,6 +97,14 @@
"source": "/bun", "source": "/bun",
"destination": "/install/bun" "destination": "/install/bun"
}, },
{
"source": "/auth-monitoring",
"destination": "/automation/auth-monitoring"
},
{
"source": "/scripts",
"destination": "/scripts"
},
{ {
"source": "/camera", "source": "/camera",
"destination": "/nodes/camera" "destination": "/nodes/camera"
@@ -617,6 +625,7 @@
{ {
"group": "Automation & Hooks", "group": "Automation & Hooks",
"pages": [ "pages": [
"automation/auth-monitoring",
"automation/webhook", "automation/webhook",
"automation/gmail-pubsub", "automation/gmail-pubsub",
"automation/cron-jobs", "automation/cron-jobs",
@@ -690,6 +699,7 @@
{ {
"group": "Reference & Templates", "group": "Reference & Templates",
"pages": [ "pages": [
"scripts",
"reference/rpc", "reference/rpc",
"reference/device-models", "reference/device-models",
"reference/test", "reference/test",

View File

@@ -1,79 +1,82 @@
---
summary: "Model authentication: OAuth, API keys, and Claude Code token reuse"
read_when:
- Debugging model auth or OAuth expiry
- Documenting authentication or credential storage
---
# Authentication # Authentication
Clawdbot uses Claude Code's authentication system for API access. By default, OAuth tokens expire every ~24 hours, requiring frequent re-authentication. For a better experience, you can set up a long-lived token that lasts **1 year**. Clawdbot supports OAuth and API keys for model providers. For Anthropic
subscription accounts, the most stable path is to **reuse Claude Code OAuth
credentials**, including the 1year token created by `claude setup-token`.
## Long-Lived Token Setup (Recommended) See [/concepts/oauth](/concepts/oauth) for the full OAuth flow and storage
layout.
Instead of daily re-auth, set up a 1-year token: ## Recommended: longlived Claude Code token
Run this on the **gateway host** (the machine running the Gateway):
```bash ```bash
claude setup-token claude setup-token
``` ```
This command will: This issues a longlived **OAuth token** (not an API key) and stores it for
1. Prompt you to visit the Anthropic console Claude Code. Then sync and verify:
2. Create or copy an API key
3. Store it for Claude Code (and Clawdbot)
After running `setup-token`, sync the credentials to Clawdbot:
```bash ```bash
clawdbot doctor --yes clawdbot models status
clawdbot doctor
``` ```
## Checking Auth Status Automation-friendly check (exit `1` when expired/missing, `2` when expiring):
To check your current authentication status:
```bash ```bash
# If you have the auth scripts installed clawdbot models status --check
~/clawdbot/scripts/claude-auth-status.sh
# Or check manually
cat ~/.claude/.credentials.json | jq '.claudeAiOauth.expiresAt'
``` ```
## How It Works Optional ops scripts (systemd/Termux) are documented here:
[/automation/auth-monitoring](/automation/auth-monitoring)
1. **Claude Code** stores credentials in `~/.claude/.credentials.json` `clawdbot models status` loads Claude Code credentials into Clawdbots
2. **Clawdbot** syncs from Claude Code to `~/.clawdbot/agents/main/agent/auth-profiles.json` `auth-profiles.json` and shows expiry (warns within 24h by default).
3. The `clawdbot doctor --yes` command triggers this sync automatically `clawdbot doctor` also performs the sync when it runs.
## Token Types > `claude setup-token` requires an interactive TTY.
| Type | Duration | Setup | ## Checking model auth status
|------|----------|-------|
| OAuth (default) | ~24 hours | Automatic on first run | ```bash
| Long-lived token | 1 year | `claude setup-token` | clawdbot models status
clawdbot doctor
```
## How sync works
1. **Claude Code** stores credentials in `~/.claude/.credentials.json` (or
Keychain on macOS).
2. **Clawdbot** syncs those into
`~/.clawdbot/agents/<agentId>/agent/auth-profiles.json` when the auth store is
loaded.
3. OAuth refresh happens automatically on use if a token is expired.
## Troubleshooting ## Troubleshooting
### "No credentials found" error ### No credentials found
Run the doctor to sync credentials: If the Anthropic OAuth profile is missing, run `claude setup-token` on the
**gateway host**, then re-check:
```bash ```bash
clawdbot doctor --yes clawdbot models status
``` ```
Then restart the service: ### Token expiring/expired
```bash Run `clawdbot models status` to confirm which profile is expiring. If the profile
systemctl --user restart clawdbot is `anthropic:claude-cli`, rerun `claude setup-token`.
```
### Token expired
If your token has expired, run `claude setup-token` again in a terminal (not from within Claude Code, as it requires an interactive TTY).
### Checking token expiry
```bash
# Check both Claude Code and Clawdbot auth
cat ~/.claude/.credentials.json | jq '.claudeAiOauth.expiresAt' | xargs -I{} date -d @$(({}/1000))
```
## Requirements ## Requirements
- Claude Max or Pro subscription (for `setup-token`) - Claude Max or Pro subscription (for `claude setup-token`)
- Claude Code CLI installed (`claude` command available) - Claude Code CLI installed (`claude` command available)

View File

@@ -61,6 +61,7 @@ cat ~/.clawdbot/clawdbot.json
- Legacy on-disk state migration (sessions/agent dir/WhatsApp auth). - Legacy on-disk state migration (sessions/agent dir/WhatsApp auth).
- State integrity and permissions checks (sessions, transcripts, state dir). - State integrity and permissions checks (sessions, transcripts, state dir).
- Config file permission checks (chmod 600) when running locally. - Config file permission checks (chmod 600) when running locally.
- Model auth health: checks OAuth expiry and can refresh expiring tokens.
- Legacy workspace dir detection (`~/clawdis`, `~/clawdbot`). - Legacy workspace dir detection (`~/clawdis`, `~/clawdbot`).
- Sandbox image repair when sandboxing is enabled. - Sandbox image repair when sandboxing is enabled.
- Legacy service migration and extra gateway detection. - Legacy service migration and extra gateway detection.
@@ -135,33 +136,40 @@ Doctor checks:
- **Config file permissions**: warns if `~/.clawdbot/clawdbot.json` is - **Config file permissions**: warns if `~/.clawdbot/clawdbot.json` is
group/world readable and offers to tighten to `600`. group/world readable and offers to tighten to `600`.
### 5) Sandbox image repair ### 5) Model auth health (OAuth expiry)
Doctor inspects OAuth profiles in the auth store, warns when tokens are
expiring/expired, and can refresh them when safe. If the Anthropic Claude Code
profile is stale, it suggests `claude setup-token` on the gateway host.
Refresh prompts only appear when running interactively (TTY); `--non-interactive`
skips refresh attempts.
### 6) Sandbox image repair
When sandboxing is enabled, doctor checks Docker images and offers to build or When sandboxing is enabled, doctor checks Docker images and offers to build or
switch to legacy names if the current image is missing. switch to legacy names if the current image is missing.
### 6) Gateway service migrations and cleanup hints ### 7) Gateway service migrations and cleanup hints
Doctor detects legacy Clawdis gateway services (launchd/systemd/schtasks) and Doctor detects legacy Clawdis gateway services (launchd/systemd/schtasks) and
offers to remove them and install the Clawdbot service using the current gateway offers to remove them and install the Clawdbot service using the current gateway
port. It can also scan for extra gateway-like services and print cleanup hints port. It can also scan for extra gateway-like services and print cleanup hints
to ensure only one gateway runs per machine. to ensure only one gateway runs per machine.
### 7) Security warnings ### 8) Security warnings
Doctor emits warnings when a provider is open to DMs without an allowlist, or Doctor emits warnings when a provider is open to DMs without an allowlist, or
when a policy is configured in a dangerous way. when a policy is configured in a dangerous way.
### 8) systemd linger (Linux) ### 9) systemd linger (Linux)
If running as a systemd user service, doctor ensures lingering is enabled so the If running as a systemd user service, doctor ensures lingering is enabled so the
gateway stays alive after logout. gateway stays alive after logout.
### 9) Skills status ### 10) Skills status
Doctor prints a quick summary of eligible/missing/blocked skills for the current Doctor prints a quick summary of eligible/missing/blocked skills for the current
workspace. workspace.
### 10) Gateway health check + restart ### 11) Gateway health check + restart
Doctor runs a health check and offers to restart the gateway when it looks Doctor runs a health check and offers to restart the gateway when it looks
unhealthy. unhealthy.
### 11) Supervisor config audit + repair ### 12) Supervisor config audit + repair
Doctor checks the installed supervisor config (launchd/systemd/schtasks) for Doctor checks the installed supervisor config (launchd/systemd/schtasks) for
missing or outdated defaults (e.g., systemd network-online dependencies and missing or outdated defaults (e.g., systemd network-online dependencies and
restart delay). When it finds a mismatch, it recommends an update and can restart delay). When it finds a mismatch, it recommends an update and can
@@ -174,24 +182,24 @@ Notes:
- `clawdbot doctor --repair --force` overwrites custom supervisor configs. - `clawdbot doctor --repair --force` overwrites custom supervisor configs.
- You can always force a full rewrite via `clawdbot daemon install --force`. - You can always force a full rewrite via `clawdbot daemon install --force`.
### 12) Gateway runtime + port diagnostics ### 13) Gateway runtime + port diagnostics
Doctor inspects the daemon runtime (PID, last exit status) and warns when the Doctor inspects the daemon runtime (PID, last exit status) and warns when the
service is installed but not actually running. It also checks for port collisions service is installed but not actually running. It also checks for port collisions
on the gateway port (default `18789`) and reports likely causes (gateway already on the gateway port (default `18789`) and reports likely causes (gateway already
running, SSH tunnel). running, SSH tunnel).
### 13) Gateway runtime best practices ### 14) Gateway runtime best practices
Doctor warns when the gateway service runs on Bun or a version-managed Node path Doctor warns when the gateway service runs on Bun or a version-managed Node path
(`nvm`, `fnm`, `volta`, `asdf`, etc.). WhatsApp + Telegram providers require Node, (`nvm`, `fnm`, `volta`, `asdf`, etc.). WhatsApp + Telegram providers require Node,
and version-manager paths can break after upgrades because the daemon does not and version-manager paths can break after upgrades because the daemon does not
load your shell init. Doctor offers to migrate to a system Node install when load your shell init. Doctor offers to migrate to a system Node install when
available (Homebrew/apt/choco). available (Homebrew/apt/choco).
### 14) Config write + wizard metadata ### 15) Config write + wizard metadata
Doctor persists any config changes and stamps wizard metadata to record the Doctor persists any config changes and stamps wizard metadata to record the
doctor run. doctor run.
### 15) Workspace tips (backup + memory system) ### 16) Workspace tips (backup + memory system)
Doctor suggests a workspace memory system when missing and prints a backup tip Doctor suggests a workspace memory system when missing and prints a backup tip
if the workspace is not already under git. if the workspace is not already under git.

26
docs/scripts.md Normal file
View File

@@ -0,0 +1,26 @@
---
summary: "Repository scripts: purpose, scope, and safety notes"
read_when:
- Running scripts from the repo
- Adding or changing scripts under ./scripts
---
# Scripts
The `scripts/` directory contains helper scripts for local workflows and ops tasks.
Use these when a task is clearly tied to a script; otherwise prefer the CLI.
## Conventions
- Scripts are **optional** unless referenced in docs or release checklists.
- Prefer CLI surfaces when they exist (example: auth monitoring uses `clawdbot models status --check`).
- Assume scripts are hostspecific; read them before running on a new machine.
## Auth monitoring scripts
Auth monitoring scripts are documented here:
[/automation/auth-monitoring](/automation/auth-monitoring)
## When adding scripts
- Keep scripts focused and documented.
- Add a short entry in the relevant doc (or create one if missing).

View File

@@ -346,7 +346,7 @@ It means the system attempted to use the auth profile ID `anthropic:default`, bu
- **Make sure youre editing the correct agent** - **Make sure youre editing the correct agent**
- Multiagent setups mean there can be multiple `auth-profiles.json` files. - Multiagent setups mean there can be multiple `auth-profiles.json` files.
- **Sanitycheck model/auth status** - **Sanitycheck model/auth status**
- Use `/model status` to see configured models and whether providers are authenticated. - Use `clawdbot models status` to see configured models and whether providers are authenticated.
### Why did it also try Google Gemini and fail? ### Why did it also try Google Gemini and fail?

View File

@@ -1,63 +0,0 @@
# Clawdbot Auth Management Scripts
## Current Setup (Jan 2025)
Using `claude setup-token` for **1-year long-lived token**. No daily re-auth needed.
- **Token expires**: January 9, 2027
- **Check status**: `./claude-auth-status.sh`
## Scripts
| Script | Purpose |
|--------|---------|
| `claude-auth-status.sh` | Check Claude Code + Clawdbot auth status |
| `mobile-reauth.sh` | Guided re-auth (only needed annually now) |
| `auth-monitor.sh` | Cron-able expiry monitor with notifications |
| `termux-quick-auth.sh` | Termux widget - one-tap status check |
| `termux-auth-widget.sh` | Termux widget - full guided re-auth flow |
| `setup-auth-system.sh` | Interactive setup wizard |
## Quick Commands
```bash
# Check auth status
~/clawdbot/scripts/claude-auth-status.sh
# Sync Claude Code token to Clawdbot
clawdbot doctor --yes
# Renew long-lived token (run in terminal, not Claude Code)
claude setup-token
```
## Termux Widget Setup (if needed)
1. Install Termux + Termux:Widget from F-Droid
2. Create shortcuts dir: `mkdir -p ~/.shortcuts`
3. Copy widget script:
```bash
scp l36:~/clawdbot/scripts/termux-quick-auth.sh ~/.shortcuts/ClawdAuth
chmod +x ~/.shortcuts/ClawdAuth
```
4. Set server: `echo 'export CLAWDBOT_SERVER=l36' >> ~/.bashrc`
5. Add Termux:Widget to home screen
## How It Works
1. `claude setup-token` creates a 1-year token in `~/.claude/.credentials.json`
2. `clawdbot doctor --yes` syncs it to `~/.clawdbot/agents/main/agent/auth-profiles.json`
3. Clawdbot uses the `anthropic:claude-cli` profile automatically
## Troubleshooting
```bash
# Check what's happening
~/clawdbot/scripts/claude-auth-status.sh full
# Force sync from Claude Code
clawdbot doctor --yes
# If token expired (annually), run in terminal:
claude setup-token
```

View File

@@ -16,20 +16,30 @@ NC='\033[0m' # No Color
# Output mode: "full" (default), "json", or "simple" # Output mode: "full" (default), "json", or "simple"
OUTPUT_MODE="${1:-full}" OUTPUT_MODE="${1:-full}"
check_claude_code_auth() { fetch_models_status_json() {
if [ ! -f "$CLAUDE_CREDS" ]; then clawdbot models status --json 2>/dev/null || true
echo "MISSING" }
return 1
fi
local expires_at STATUS_JSON="$(fetch_models_status_json)"
expires_at=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS") USE_JSON=0
local now_ms=$(($(date +%s) * 1000)) if [ -n "$STATUS_JSON" ]; then
USE_JSON=1
fi
calc_status_from_expires() {
local expires_at="$1"
if ! [[ "$expires_at" =~ ^-?[0-9]+$ ]]; then
expires_at=0
fi
local now_ms=$(( $(date +%s) * 1000 ))
local diff_ms=$((expires_at - now_ms)) local diff_ms=$((expires_at - now_ms))
local hours=$((diff_ms / 3600000)) local hours=$((diff_ms / 3600000))
local mins=$(((diff_ms % 3600000) / 60000)) local mins=$(((diff_ms % 3600000) / 60000))
if [ "$diff_ms" -lt 0 ]; then if [ "$expires_at" -le 0 ]; then
echo "MISSING"
return 1
elif [ "$diff_ms" -lt 0 ]; then
echo "EXPIRED" echo "EXPIRED"
return 1 return 1
elif [ "$diff_ms" -lt 3600000 ]; then elif [ "$diff_ms" -lt 3600000 ]; then
@@ -41,34 +51,89 @@ check_claude_code_auth() {
fi fi
} }
json_expires_for_claude_cli() {
echo "$STATUS_JSON" | jq -r '
[.auth.oauth.profiles[]
| select(.provider == "anthropic" and .type == "oauth" and .source == "claude-cli")
| .expiresAt // 0]
| max // 0
' 2>/dev/null || echo "0"
}
json_expires_for_anthropic_any() {
echo "$STATUS_JSON" | jq -r '
[.auth.oauth.profiles[]
| select(.provider == "anthropic" and .type == "oauth")
| .expiresAt // 0]
| max // 0
' 2>/dev/null || echo "0"
}
json_best_anthropic_profile() {
echo "$STATUS_JSON" | jq -r '
[.auth.oauth.profiles[]
| select(.provider == "anthropic" and .type == "oauth")
| {id: .profileId, exp: (.expiresAt // 0)}]
| sort_by(.exp) | reverse | .[0].id // "none"
' 2>/dev/null || echo "none"
}
json_anthropic_api_key_count() {
echo "$STATUS_JSON" | jq -r '
[.auth.providers[] | select(.provider == "anthropic") | .profiles.apiKey]
| max // 0
' 2>/dev/null || echo "0"
}
check_claude_code_auth() {
if [ "$USE_JSON" -eq 1 ]; then
local expires_at
expires_at=$(json_expires_for_claude_cli)
calc_status_from_expires "$expires_at"
return $?
fi
if [ ! -f "$CLAUDE_CREDS" ]; then
echo "MISSING"
return 1
fi
local expires_at
expires_at=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0")
calc_status_from_expires "$expires_at"
}
check_clawdbot_auth() { check_clawdbot_auth() {
if [ "$USE_JSON" -eq 1 ]; then
local api_keys
api_keys=$(json_anthropic_api_key_count)
if ! [[ "$api_keys" =~ ^[0-9]+$ ]]; then
api_keys=0
fi
local expires_at
expires_at=$(json_expires_for_anthropic_any)
if [ "$expires_at" -le 0 ] && [ "$api_keys" -gt 0 ]; then
echo "OK:static"
return 0
fi
calc_status_from_expires "$expires_at"
return $?
fi
if [ ! -f "$CLAWDBOT_AUTH" ]; then if [ ! -f "$CLAWDBOT_AUTH" ]; then
echo "MISSING" echo "MISSING"
return 1 return 1
fi fi
# Find the best Anthropic profile (prefer claude-cli, then any with latest expiry)
local expires local expires
expires=$(jq -r ' expires=$(jq -r '
[.profiles | to_entries[] | select(.value.provider == "anthropic") | .value.expires] [.profiles | to_entries[] | select(.value.provider == "anthropic") | .value.expires]
| max // 0 | max // 0
' "$CLAWDBOT_AUTH") ' "$CLAWDBOT_AUTH" 2>/dev/null || echo "0")
local now_ms=$(($(date +%s) * 1000)) calc_status_from_expires "$expires"
local diff_ms=$((expires - now_ms))
local hours=$((diff_ms / 3600000))
local mins=$(((diff_ms % 3600000) / 60000))
if [ "$diff_ms" -lt 0 ]; then
echo "EXPIRED"
return 1
elif [ "$diff_ms" -lt 3600000 ]; then
echo "EXPIRING:${mins}m"
return 2
else
echo "OK:${hours}h${mins}m"
return 0
fi
} }
# JSON output mode # JSON output mode
@@ -76,8 +141,15 @@ if [ "$OUTPUT_MODE" = "json" ]; then
claude_status=$(check_claude_code_auth 2>/dev/null || true) claude_status=$(check_claude_code_auth 2>/dev/null || true)
clawdbot_status=$(check_clawdbot_auth 2>/dev/null || true) clawdbot_status=$(check_clawdbot_auth 2>/dev/null || true)
claude_expires=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0") claude_expires=0
clawdbot_expires=$(jq -r '.profiles["anthropic:default"].expires // 0' "$CLAWDBOT_AUTH" 2>/dev/null || echo "0") clawdbot_expires=0
if [ "$USE_JSON" -eq 1 ]; then
claude_expires=$(json_expires_for_claude_cli)
clawdbot_expires=$(json_expires_for_anthropic_any)
else
claude_expires=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0")
clawdbot_expires=$(jq -r '.profiles["anthropic:default"].expires // 0' "$CLAWDBOT_AUTH" 2>/dev/null || echo "0")
fi
jq -n \ jq -n \
--arg cs "$claude_status" \ --arg cs "$claude_status" \
@@ -121,19 +193,28 @@ echo ""
# Claude Code credentials # Claude Code credentials
echo "Claude Code (~/.claude/.credentials.json):" echo "Claude Code (~/.claude/.credentials.json):"
if [ -f "$CLAUDE_CREDS" ]; then if [ "$USE_JSON" -eq 1 ]; then
expires_at=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS") expires_at=$(json_expires_for_claude_cli)
sub_type=$(jq -r '.claudeAiOauth.subscriptionType // "unknown"' "$CLAUDE_CREDS") else
rate_tier=$(jq -r '.claudeAiOauth.rateLimitTier // "unknown"' "$CLAUDE_CREDS") expires_at=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0")
fi
now_ms=$(($(date +%s) * 1000)) if [ -f "$CLAUDE_CREDS" ]; then
sub_type=$(jq -r '.claudeAiOauth.subscriptionType // "unknown"' "$CLAUDE_CREDS" 2>/dev/null || echo "unknown")
rate_tier=$(jq -r '.claudeAiOauth.rateLimitTier // "unknown"' "$CLAUDE_CREDS" 2>/dev/null || echo "unknown")
echo " Subscription: $sub_type"
echo " Rate tier: $rate_tier"
fi
if [ "$expires_at" -le 0 ]; then
echo -e " Status: ${RED}NOT FOUND${NC}"
echo " Action needed: Run 'claude setup-token'"
else
now_ms=$(( $(date +%s) * 1000 ))
diff_ms=$((expires_at - now_ms)) diff_ms=$((expires_at - now_ms))
hours=$((diff_ms / 3600000)) hours=$((diff_ms / 3600000))
mins=$(((diff_ms % 3600000) / 60000)) mins=$(((diff_ms % 3600000) / 60000))
echo " Subscription: $sub_type"
echo " Rate tier: $rate_tier"
if [ "$diff_ms" -lt 0 ]; then if [ "$diff_ms" -lt 0 ]; then
echo -e " Status: ${RED}EXPIRED${NC}" echo -e " Status: ${RED}EXPIRED${NC}"
echo " Action needed: Run 'claude setup-token' or re-authenticate" echo " Action needed: Run 'claude setup-token' or re-authenticate"
@@ -144,34 +225,41 @@ if [ -f "$CLAUDE_CREDS" ]; then
echo -e " Status: ${GREEN}OK${NC}" echo -e " Status: ${GREEN}OK${NC}"
echo " Expires: $(date -d @$((expires_at/1000))) (${hours}h ${mins}m)" echo " Expires: $(date -d @$((expires_at/1000))) (${hours}h ${mins}m)"
fi fi
else
echo -e " Status: ${RED}NOT FOUND${NC}"
echo " Action needed: Run 'claude setup-token'"
fi fi
echo "" echo ""
echo "Clawdbot Auth (~/.clawdbot/agents/main/agent/auth-profiles.json):" echo "Clawdbot Auth (~/.clawdbot/agents/main/agent/auth-profiles.json):"
if [ -f "$CLAWDBOT_AUTH" ]; then if [ "$USE_JSON" -eq 1 ]; then
# Find best Anthropic profile best_profile=$(json_best_anthropic_profile)
expires=$(json_expires_for_anthropic_any)
api_keys=$(json_anthropic_api_key_count)
else
best_profile=$(jq -r ' best_profile=$(jq -r '
.profiles | to_entries .profiles | to_entries
| map(select(.value.provider == "anthropic")) | map(select(.value.provider == "anthropic"))
| sort_by(.value.expires) | reverse | sort_by(.value.expires) | reverse
| .[0].key // "none" | .[0].key // "none"
' "$CLAWDBOT_AUTH") ' "$CLAWDBOT_AUTH" 2>/dev/null || echo "none")
expires=$(jq -r ' expires=$(jq -r '
[.profiles | to_entries[] | select(.value.provider == "anthropic") | .value.expires] [.profiles | to_entries[] | select(.value.provider == "anthropic") | .value.expires]
| max // 0 | max // 0
' "$CLAWDBOT_AUTH") ' "$CLAWDBOT_AUTH" 2>/dev/null || echo "0")
api_keys=0
fi
now_ms=$(($(date +%s) * 1000)) echo " Profile: $best_profile"
if [ "$expires" -le 0 ] && [ "$api_keys" -gt 0 ]; then
echo -e " Status: ${GREEN}OK${NC} (API key)"
elif [ "$expires" -le 0 ]; then
echo -e " Status: ${RED}NOT FOUND${NC}"
echo " Note: Run 'clawdbot doctor --yes' to sync from Claude Code"
else
now_ms=$(( $(date +%s) * 1000 ))
diff_ms=$((expires - now_ms)) diff_ms=$((expires - now_ms))
hours=$((diff_ms / 3600000)) hours=$((diff_ms / 3600000))
mins=$(((diff_ms % 3600000) / 60000)) mins=$(((diff_ms % 3600000) / 60000))
echo " Profile: $best_profile"
if [ "$diff_ms" -lt 0 ]; then if [ "$diff_ms" -lt 0 ]; then
echo -e " Status: ${RED}EXPIRED${NC}" echo -e " Status: ${RED}EXPIRED${NC}"
echo " Note: Run 'clawdbot doctor --yes' to sync from Claude Code" echo " Note: Run 'clawdbot doctor --yes' to sync from Claude Code"
@@ -181,8 +269,6 @@ if [ -f "$CLAWDBOT_AUTH" ]; then
echo -e " Status: ${GREEN}OK${NC}" echo -e " Status: ${GREEN}OK${NC}"
echo " Expires: $(date -d @$((expires/1000))) (${hours}h ${mins}m)" echo " Expires: $(date -d @$((expires/1000))) (${hours}h ${mins}m)"
fi fi
else
echo -e " Status: ${RED}NOT FOUND${NC}"
fi fi
echo "" echo ""

View File

@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildAuthHealthSummary,
DEFAULT_OAUTH_WARN_MS,
} from "./auth-health.js";
describe("buildAuthHealthSummary", () => {
const now = 1_700_000_000_000;
afterEach(() => {
vi.restoreAllMocks();
});
it("classifies OAuth and API key profiles", () => {
vi.spyOn(Date, "now").mockReturnValue(now);
const store = {
version: 1,
profiles: {
"anthropic:ok": {
type: "oauth" as const,
provider: "anthropic",
access: "access",
refresh: "refresh",
expires: now + DEFAULT_OAUTH_WARN_MS + 60_000,
},
"anthropic:expiring": {
type: "oauth" as const,
provider: "anthropic",
access: "access",
refresh: "refresh",
expires: now + 10_000,
},
"anthropic:expired": {
type: "oauth" as const,
provider: "anthropic",
access: "access",
refresh: "refresh",
expires: now - 10_000,
},
"anthropic:api": {
type: "api_key" as const,
provider: "anthropic",
key: "sk-ant-api",
},
},
};
const summary = buildAuthHealthSummary({
store,
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
});
const statuses = Object.fromEntries(
summary.profiles.map((profile) => [profile.profileId, profile.status]),
);
expect(statuses["anthropic:ok"]).toBe("ok");
expect(statuses["anthropic:expiring"]).toBe("expiring");
expect(statuses["anthropic:expired"]).toBe("expired");
expect(statuses["anthropic:api"]).toBe("static");
const provider = summary.providers.find(
(entry) => entry.provider === "anthropic",
);
expect(provider?.status).toBe("expired");
});
});

227
src/agents/auth-health.ts Normal file
View File

@@ -0,0 +1,227 @@
import type { ClawdbotConfig } from "../config/config.js";
import {
type AuthProfileCredential,
type AuthProfileStore,
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
resolveAuthProfileDisplayLabel,
} from "./auth-profiles.js";
export type AuthProfileSource = "claude-cli" | "codex-cli" | "store";
export type AuthProfileHealthStatus =
| "ok"
| "expiring"
| "expired"
| "missing"
| "static";
export type AuthProfileHealth = {
profileId: string;
provider: string;
type: "oauth" | "api_key";
status: AuthProfileHealthStatus;
expiresAt?: number;
remainingMs?: number;
source: AuthProfileSource;
label: string;
};
export type AuthProviderHealthStatus =
| "ok"
| "expiring"
| "expired"
| "missing"
| "static";
export type AuthProviderHealth = {
provider: string;
status: AuthProviderHealthStatus;
expiresAt?: number;
remainingMs?: number;
profiles: AuthProfileHealth[];
};
export type AuthHealthSummary = {
now: number;
warnAfterMs: number;
profiles: AuthProfileHealth[];
providers: AuthProviderHealth[];
};
export const DEFAULT_OAUTH_WARN_MS = 24 * 60 * 60 * 1000;
export function resolveAuthProfileSource(profileId: string): AuthProfileSource {
if (profileId === CLAUDE_CLI_PROFILE_ID) return "claude-cli";
if (profileId === CODEX_CLI_PROFILE_ID) return "codex-cli";
return "store";
}
export function formatRemainingShort(remainingMs?: number): string {
if (remainingMs === undefined || Number.isNaN(remainingMs)) return "unknown";
if (remainingMs <= 0) return "0m";
const minutes = Math.max(1, Math.round(remainingMs / 60_000));
if (minutes < 60) return `${minutes}m`;
const hours = Math.round(minutes / 60);
if (hours < 48) return `${hours}h`;
const days = Math.round(hours / 24);
return `${days}d`;
}
function resolveOAuthStatus(
expiresAt: number | undefined,
now: number,
warnAfterMs: number,
): { status: AuthProfileHealthStatus; remainingMs?: number } {
if (!expiresAt || !Number.isFinite(expiresAt) || expiresAt <= 0) {
return { status: "missing" };
}
const remainingMs = expiresAt - now;
if (remainingMs <= 0) {
return { status: "expired", remainingMs };
}
if (remainingMs <= warnAfterMs) {
return { status: "expiring", remainingMs };
}
return { status: "ok", remainingMs };
}
function buildProfileHealth(params: {
profileId: string;
credential: AuthProfileCredential;
store: AuthProfileStore;
cfg?: ClawdbotConfig;
now: number;
warnAfterMs: number;
}): AuthProfileHealth {
const { profileId, credential, store, cfg, now, warnAfterMs } = params;
const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
const source = resolveAuthProfileSource(profileId);
if (credential.type === "api_key") {
return {
profileId,
provider: credential.provider,
type: "api_key",
status: "static",
source,
label,
};
}
const { status, remainingMs } = resolveOAuthStatus(
credential.expires,
now,
warnAfterMs,
);
return {
profileId,
provider: credential.provider,
type: "oauth",
status,
expiresAt: credential.expires,
remainingMs,
source,
label,
};
}
export function buildAuthHealthSummary(params: {
store: AuthProfileStore;
cfg?: ClawdbotConfig;
warnAfterMs?: number;
providers?: string[];
}): AuthHealthSummary {
const now = Date.now();
const warnAfterMs = params.warnAfterMs ?? DEFAULT_OAUTH_WARN_MS;
const providerFilter = params.providers
? new Set(params.providers.map((p) => p.trim()).filter(Boolean))
: null;
const profiles = Object.entries(params.store.profiles)
.filter(([_, cred]) =>
providerFilter ? providerFilter.has(cred.provider) : true,
)
.map(([profileId, credential]) =>
buildProfileHealth({
profileId,
credential,
store: params.store,
cfg: params.cfg,
now,
warnAfterMs,
}),
)
.sort((a, b) => {
if (a.provider !== b.provider) {
return a.provider.localeCompare(b.provider);
}
return a.profileId.localeCompare(b.profileId);
});
const providersMap = new Map<string, AuthProviderHealth>();
for (const profile of profiles) {
const existing = providersMap.get(profile.provider);
if (!existing) {
providersMap.set(profile.provider, {
provider: profile.provider,
status: "missing",
profiles: [profile],
});
} else {
existing.profiles.push(profile);
}
}
if (providerFilter) {
for (const provider of providerFilter) {
if (!providersMap.has(provider)) {
providersMap.set(provider, {
provider,
status: "missing",
profiles: [],
});
}
}
}
for (const provider of providersMap.values()) {
if (provider.profiles.length === 0) {
provider.status = "missing";
continue;
}
const oauthProfiles = provider.profiles.filter((p) => p.type === "oauth");
const apiKeyProfiles = provider.profiles.filter(
(p) => p.type === "api_key",
);
if (oauthProfiles.length === 0) {
provider.status = apiKeyProfiles.length > 0 ? "static" : "missing";
continue;
}
const expiryCandidates = oauthProfiles
.map((p) => p.expiresAt)
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
if (expiryCandidates.length > 0) {
provider.expiresAt = Math.min(...expiryCandidates);
provider.remainingMs = provider.expiresAt - now;
}
const statuses = oauthProfiles.map((p) => p.status);
if (statuses.includes("expired") || statuses.includes("missing")) {
provider.status = "expired";
} else if (statuses.includes("expiring")) {
provider.status = "expiring";
} else {
provider.status = "ok";
}
}
const providers = Array.from(providersMap.values()).sort((a, b) =>
a.provider.localeCompare(b.provider),
);
return { now, warnAfterMs, profiles, providers };
}

View File

@@ -57,6 +57,11 @@ export function registerModelsCli(program: Command) {
.description("Show configured model state") .description("Show configured model state")
.option("--json", "Output JSON", false) .option("--json", "Output JSON", false)
.option("--plain", "Plain output", false) .option("--plain", "Plain output", false)
.option(
"--check",
"Exit non-zero if auth is expiring/expired (1=expired/missing, 2=expiring)",
false,
)
.action(async (opts) => { .action(async (opts) => {
try { try {
await modelsStatusCommand(opts, defaultRuntime); await modelsStatusCommand(opts, defaultRuntime);

View File

@@ -1,8 +1,16 @@
import { note } from "@clack/prompts"; import { note } from "@clack/prompts";
import { import {
buildAuthHealthSummary,
DEFAULT_OAUTH_WARN_MS,
formatRemainingShort,
} from "../agents/auth-health.js";
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore, ensureAuthProfileStore,
repairOAuthProfileIdMismatch, repairOAuthProfileIdMismatch,
resolveApiKeyForProfile,
} from "../agents/auth-profiles.js"; } from "../agents/auth-profiles.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import type { DoctorPrompter } from "./doctor-prompter.js"; import type { DoctorPrompter } from "./doctor-prompter.js";
@@ -28,3 +36,114 @@ export async function maybeRepairAnthropicOAuthProfileId(
if (!apply) return cfg; if (!apply) return cfg;
return repair.config; return repair.config;
} }
type AuthIssue = {
profileId: string;
provider: string;
status: string;
remainingMs?: number;
};
function formatAuthIssueHint(issue: AuthIssue): string | null {
if (
issue.provider === "anthropic" &&
issue.profileId === CLAUDE_CLI_PROFILE_ID
) {
return "Run `claude setup-token` on the gateway host.";
}
if (
issue.provider === "openai-codex" &&
issue.profileId === CODEX_CLI_PROFILE_ID
) {
return "Run `codex login` (or `clawdbot configure` → OpenAI Codex OAuth).";
}
return "Re-auth via `clawdbot configure` or `clawdbot onboard`.";
}
function formatAuthIssueLine(issue: AuthIssue): string {
const remaining =
issue.remainingMs !== undefined
? ` (${formatRemainingShort(issue.remainingMs)})`
: "";
const hint = formatAuthIssueHint(issue);
return `- ${issue.profileId}: ${issue.status}${remaining}${hint ? `${hint}` : ""}`;
}
export async function noteAuthProfileHealth(params: {
cfg: ClawdbotConfig;
prompter: DoctorPrompter;
allowKeychainPrompt: boolean;
}): Promise<void> {
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: params.allowKeychainPrompt,
});
let summary = buildAuthHealthSummary({
store,
cfg: params.cfg,
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
});
const findIssues = () =>
summary.profiles.filter(
(profile) =>
profile.type === "oauth" &&
(profile.status === "expired" ||
profile.status === "expiring" ||
profile.status === "missing"),
);
let issues = findIssues();
if (issues.length === 0) return;
const shouldRefresh = await params.prompter.confirmRepair({
message: "Refresh expiring OAuth tokens now?",
initialValue: true,
});
if (shouldRefresh) {
const refreshTargets = issues.filter((issue) =>
["expired", "expiring", "missing"].includes(issue.status),
);
const errors: string[] = [];
for (const profile of refreshTargets) {
try {
await resolveApiKeyForProfile({
cfg: params.cfg,
store,
profileId: profile.profileId,
});
} catch (err) {
errors.push(
`- ${profile.profileId}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
if (errors.length > 0) {
note(errors.join("\n"), "OAuth refresh errors");
}
summary = buildAuthHealthSummary({
store: ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
}),
cfg: params.cfg,
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
});
issues = findIssues();
}
if (issues.length > 0) {
note(
issues
.map((issue) =>
formatAuthIssueLine({
profileId: issue.profileId,
provider: issue.provider,
status: issue.status,
remainingMs: issue.remainingMs,
}),
)
.join("\n"),
"Model auth",
);
}
}

View File

@@ -26,7 +26,10 @@ import {
GATEWAY_DAEMON_RUNTIME_OPTIONS, GATEWAY_DAEMON_RUNTIME_OPTIONS,
type GatewayDaemonRuntime, type GatewayDaemonRuntime,
} from "./daemon-runtime.js"; } from "./daemon-runtime.js";
import { maybeRepairAnthropicOAuthProfileId } from "./doctor-auth.js"; import {
maybeRepairAnthropicOAuthProfileId,
noteAuthProfileHealth,
} from "./doctor-auth.js";
import { import {
buildGatewayRuntimeHints, buildGatewayRuntimeHints,
formatGatewayRuntimeSummary, formatGatewayRuntimeSummary,
@@ -124,6 +127,12 @@ export async function doctorCommand(
} }
cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter); cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter);
await noteAuthProfileHealth({
cfg,
prompter,
allowKeychainPrompt:
options.nonInteractive !== true && Boolean(process.stdin.isTTY),
});
const gatewayDetails = buildGatewayConnectionDetails({ config: cfg }); const gatewayDetails = buildGatewayConnectionDetails({ config: cfg });
if (gatewayDetails.remoteFallbackNote) { if (gatewayDetails.remoteFallbackNote) {
note(gatewayDetails.remoteFallbackNote, "Gateway"); note(gatewayDetails.remoteFallbackNote, "Gateway");

View File

@@ -77,12 +77,17 @@ vi.mock("../../agents/agent-paths.js", () => ({
resolveClawdbotAgentDir: mocks.resolveClawdbotAgentDir, resolveClawdbotAgentDir: mocks.resolveClawdbotAgentDir,
})); }));
vi.mock("../../agents/auth-profiles.js", () => ({ vi.mock("../../agents/auth-profiles.js", async (importOriginal) => {
ensureAuthProfileStore: mocks.ensureAuthProfileStore, const actual =
listProfilesForProvider: mocks.listProfilesForProvider, await importOriginal<typeof import("../../agents/auth-profiles.js")>();
resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel, return {
resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay, ...actual,
})); ensureAuthProfileStore: mocks.ensureAuthProfileStore,
listProfilesForProvider: mocks.listProfilesForProvider,
resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel,
resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay,
};
});
vi.mock("../../agents/model-auth.js", () => ({ vi.mock("../../agents/model-auth.js", () => ({
resolveEnvApiKey: mocks.resolveEnvApiKey, resolveEnvApiKey: mocks.resolveEnvApiKey,
@@ -126,6 +131,9 @@ describe("modelsStatusCommand auth overview", () => {
expect(payload.auth.shellEnvFallback.appliedKeys).toContain( expect(payload.auth.shellEnvFallback.appliedKeys).toContain(
"OPENAI_API_KEY", "OPENAI_API_KEY",
); );
expect(payload.auth.missingProvidersInUse).toEqual([]);
expect(payload.auth.oauth.warnAfterMs).toBeGreaterThan(0);
expect(payload.auth.oauth.profiles.length).toBeGreaterThan(0);
const providers = payload.auth.providers as Array<{ const providers = payload.auth.providers as Array<{
provider: string; provider: string;
@@ -152,4 +160,27 @@ describe("modelsStatusCommand auth overview", () => {
), ),
).toBe(true); ).toBe(true);
}); });
it("exits non-zero when auth is missing", async () => {
const originalProfiles = { ...mocks.store.profiles };
mocks.store.profiles = {};
const localRuntime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation();
mocks.resolveEnvApiKey.mockImplementation(() => null);
try {
await modelsStatusCommand(
{ check: true, plain: true },
localRuntime as never,
);
expect(localRuntime.exit).toHaveBeenCalledWith(1);
} finally {
mocks.store.profiles = originalProfiles;
mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl);
}
});
}); });

View File

@@ -7,6 +7,11 @@ import {
} from "@mariozechner/pi-coding-agent"; } from "@mariozechner/pi-coding-agent";
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js"; import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import {
buildAuthHealthSummary,
DEFAULT_OAUTH_WARN_MS,
formatRemainingShort,
} from "../../agents/auth-health.js";
import { import {
type AuthProfileStore, type AuthProfileStore,
ensureAuthProfileStore, ensureAuthProfileStore,
@@ -599,7 +604,7 @@ export async function modelsListCommand(
} }
export async function modelsStatusCommand( export async function modelsStatusCommand(
opts: { json?: boolean; plain?: boolean }, opts: { json?: boolean; plain?: boolean; check?: boolean },
runtime: RuntimeEnv, runtime: RuntimeEnv,
) { ) {
ensureFlagCompatibility(opts); ensureFlagCompatibility(opts);
@@ -656,6 +661,7 @@ export async function modelsStatusCommand(
.filter(Boolean), .filter(Boolean),
); );
const providersFromModels = new Set<string>(); const providersFromModels = new Set<string>();
const providersInUse = new Set<string>();
for (const raw of [ for (const raw of [
defaultLabel, defaultLabel,
...fallbacks, ...fallbacks,
@@ -666,6 +672,15 @@ export async function modelsStatusCommand(
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER); const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
if (parsed?.provider) providersFromModels.add(parsed.provider); if (parsed?.provider) providersFromModels.add(parsed.provider);
} }
for (const raw of [
defaultLabel,
...fallbacks,
imageModel,
...imageFallbacks,
]) {
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
if (parsed?.provider) providersInUse.add(parsed.provider);
}
const providersFromEnv = new Set<string>(); const providersFromEnv = new Set<string>();
// Keep in sync with resolveEnvApiKey() mappings (we want visibility even when // Keep in sync with resolveEnvApiKey() mappings (we want visibility even when
@@ -715,6 +730,12 @@ export async function modelsStatusCommand(
Boolean(entry.modelsJson); Boolean(entry.modelsJson);
return hasAny; return hasAny;
}); });
const providerAuthMap = new Map(
providerAuth.map((entry) => [entry.provider, entry]),
);
const missingProvidersInUse = Array.from(providersInUse)
.filter((provider) => !providerAuthMap.has(provider))
.sort((a, b) => a.localeCompare(b));
const providersWithOauth = providerAuth const providersWithOauth = providerAuth
.filter( .filter(
@@ -726,6 +747,29 @@ export async function modelsStatusCommand(
return `${entry.provider} (${count})`; return `${entry.provider} (${count})`;
}); });
const authHealth = buildAuthHealthSummary({
store,
cfg,
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
providers,
});
const oauthProfiles = authHealth.profiles.filter(
(profile) => profile.type === "oauth",
);
const checkStatus = (() => {
const hasExpiredOrMissing =
oauthProfiles.some((profile) =>
["expired", "missing"].includes(profile.status),
) || missingProvidersInUse.length > 0;
const hasExpiring = oauthProfiles.some(
(profile) => profile.status === "expiring",
);
if (hasExpiredOrMissing) return 1;
if (hasExpiring) return 2;
return 0;
})();
if (opts.json) { if (opts.json) {
runtime.log( runtime.log(
JSON.stringify( JSON.stringify(
@@ -746,18 +790,30 @@ export async function modelsStatusCommand(
appliedKeys: applied, appliedKeys: applied,
}, },
providersWithOAuth: providersWithOauth, providersWithOAuth: providersWithOauth,
missingProvidersInUse,
providers: providerAuth, providers: providerAuth,
oauth: {
warnAfterMs: authHealth.warnAfterMs,
profiles: authHealth.profiles,
providers: authHealth.providers,
},
}, },
}, },
null, null,
2, 2,
), ),
); );
if (opts.check) {
runtime.exit(checkStatus);
}
return; return;
} }
if (opts.plain) { if (opts.plain) {
runtime.log(resolvedLabel); runtime.log(resolvedLabel);
if (opts.check) {
runtime.exit(checkStatus);
}
return; return;
} }
@@ -933,4 +989,48 @@ export async function modelsStatusCommand(
} }
runtime.log(`- ${theme.heading(entry.provider)} ${bits.join(separator)}`); runtime.log(`- ${theme.heading(entry.provider)} ${bits.join(separator)}`);
} }
if (missingProvidersInUse.length > 0) {
runtime.log("");
runtime.log(colorize(rich, theme.heading, "Missing auth"));
for (const provider of missingProvidersInUse) {
const hint =
provider === "anthropic"
? "Run `claude setup-token` or `clawdbot configure`."
: "Run `clawdbot configure` or set an API key env var.";
runtime.log(`- ${theme.heading(provider)} ${hint}`);
}
}
runtime.log("");
runtime.log(colorize(rich, theme.heading, "OAuth status"));
if (oauthProfiles.length === 0) {
runtime.log(colorize(rich, theme.muted, "- none"));
return;
}
const formatStatus = (status: string) => {
if (status === "ok") return colorize(rich, theme.success, "ok");
if (status === "expiring") return colorize(rich, theme.warn, "expiring");
if (status === "missing") return colorize(rich, theme.warn, "unknown");
return colorize(rich, theme.error, "expired");
};
for (const profile of oauthProfiles) {
const labelText = profile.label || profile.profileId;
const label = colorize(rich, theme.accent, labelText);
const status = formatStatus(profile.status);
const expiry = profile.expiresAt
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
: " expires unknown";
const source =
profile.source !== "store"
? colorize(rich, theme.muted, ` (${profile.source})`)
: "";
runtime.log(`- ${label} ${status}${expiry}${source}`);
}
if (opts.check) {
runtime.exit(checkStatus);
}
} }