feat(models): add oauth auth health
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
|
||||
41
docs/automation/auth-monitoring.md
Normal file
41
docs/automation/auth-monitoring.md
Normal 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 re‑auth flow over SSH.
|
||||
- `scripts/termux-quick-auth.sh`: one‑tap 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 don’t need phone automation or systemd timers, skip these scripts.
|
||||
@@ -479,6 +479,9 @@ Options:
|
||||
Options:
|
||||
- `--json`
|
||||
- `--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>`
|
||||
Set `agent.model.primary`.
|
||||
|
||||
@@ -71,7 +71,14 @@ Shows configured models by default. Useful flags:
|
||||
### `models status`
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
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 can’t access the entry.
|
||||
|
||||
How to verify:
|
||||
|
||||
```bash
|
||||
clawdbot models status
|
||||
clawdbot providers list
|
||||
```
|
||||
|
||||
|
||||
@@ -97,6 +97,14 @@
|
||||
"source": "/bun",
|
||||
"destination": "/install/bun"
|
||||
},
|
||||
{
|
||||
"source": "/auth-monitoring",
|
||||
"destination": "/automation/auth-monitoring"
|
||||
},
|
||||
{
|
||||
"source": "/scripts",
|
||||
"destination": "/scripts"
|
||||
},
|
||||
{
|
||||
"source": "/camera",
|
||||
"destination": "/nodes/camera"
|
||||
@@ -617,6 +625,7 @@
|
||||
{
|
||||
"group": "Automation & Hooks",
|
||||
"pages": [
|
||||
"automation/auth-monitoring",
|
||||
"automation/webhook",
|
||||
"automation/gmail-pubsub",
|
||||
"automation/cron-jobs",
|
||||
@@ -690,6 +699,7 @@
|
||||
{
|
||||
"group": "Reference & Templates",
|
||||
"pages": [
|
||||
"scripts",
|
||||
"reference/rpc",
|
||||
"reference/device-models",
|
||||
"reference/test",
|
||||
|
||||
@@ -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
|
||||
|
||||
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 1‑year 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: long‑lived Claude Code token
|
||||
|
||||
Run this on the **gateway host** (the machine running the Gateway):
|
||||
|
||||
```bash
|
||||
claude setup-token
|
||||
```
|
||||
|
||||
This command will:
|
||||
1. Prompt you to visit the Anthropic console
|
||||
2. Create or copy an API key
|
||||
3. Store it for Claude Code (and Clawdbot)
|
||||
|
||||
After running `setup-token`, sync the credentials to Clawdbot:
|
||||
This issues a long‑lived **OAuth token** (not an API key) and stores it for
|
||||
Claude Code. Then sync and verify:
|
||||
|
||||
```bash
|
||||
clawdbot doctor --yes
|
||||
clawdbot models status
|
||||
clawdbot doctor
|
||||
```
|
||||
|
||||
## Checking Auth Status
|
||||
|
||||
To check your current authentication status:
|
||||
Automation-friendly check (exit `1` when expired/missing, `2` when expiring):
|
||||
|
||||
```bash
|
||||
# If you have the auth scripts installed
|
||||
~/clawdbot/scripts/claude-auth-status.sh
|
||||
|
||||
# Or check manually
|
||||
cat ~/.claude/.credentials.json | jq '.claudeAiOauth.expiresAt'
|
||||
clawdbot models status --check
|
||||
```
|
||||
|
||||
## 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`
|
||||
2. **Clawdbot** syncs from Claude Code to `~/.clawdbot/agents/main/agent/auth-profiles.json`
|
||||
3. The `clawdbot doctor --yes` command triggers this sync automatically
|
||||
`clawdbot models status` loads Claude Code credentials into Clawdbot’s
|
||||
`auth-profiles.json` and shows expiry (warns within 24h by default).
|
||||
`clawdbot doctor` also performs the sync when it runs.
|
||||
|
||||
## Token Types
|
||||
> `claude setup-token` requires an interactive TTY.
|
||||
|
||||
| Type | Duration | Setup |
|
||||
|------|----------|-------|
|
||||
| OAuth (default) | ~24 hours | Automatic on first run |
|
||||
| Long-lived token | 1 year | `claude setup-token` |
|
||||
## Checking model auth status
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
### "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
|
||||
clawdbot doctor --yes
|
||||
clawdbot models status
|
||||
```
|
||||
|
||||
Then restart the service:
|
||||
### Token expiring/expired
|
||||
|
||||
```bash
|
||||
systemctl --user restart clawdbot
|
||||
```
|
||||
|
||||
### 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))
|
||||
```
|
||||
Run `clawdbot models status` to confirm which profile is expiring. If the profile
|
||||
is `anthropic:claude-cli`, rerun `claude setup-token`.
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -61,6 +61,7 @@ cat ~/.clawdbot/clawdbot.json
|
||||
- Legacy on-disk state migration (sessions/agent dir/WhatsApp auth).
|
||||
- State integrity and permissions checks (sessions, transcripts, state dir).
|
||||
- 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`).
|
||||
- Sandbox image repair when sandboxing is enabled.
|
||||
- Legacy service migration and extra gateway detection.
|
||||
@@ -135,33 +136,40 @@ Doctor checks:
|
||||
- **Config file permissions**: warns if `~/.clawdbot/clawdbot.json` is
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
gateway stays alive after logout.
|
||||
|
||||
### 9) Skills status
|
||||
### 10) Skills status
|
||||
Doctor prints a quick summary of eligible/missing/blocked skills for the current
|
||||
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
|
||||
unhealthy.
|
||||
|
||||
### 11) Supervisor config audit + repair
|
||||
### 12) Supervisor config audit + repair
|
||||
Doctor checks the installed supervisor config (launchd/systemd/schtasks) for
|
||||
missing or outdated defaults (e.g., systemd network-online dependencies and
|
||||
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.
|
||||
- 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
|
||||
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
|
||||
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
|
||||
(`nvm`, `fnm`, `volta`, `asdf`, etc.). WhatsApp + Telegram providers require Node,
|
||||
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
|
||||
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 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
|
||||
if the workspace is not already under git.
|
||||
|
||||
|
||||
26
docs/scripts.md
Normal file
26
docs/scripts.md
Normal 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 host‑specific; 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).
|
||||
@@ -346,7 +346,7 @@ It means the system attempted to use the auth profile ID `anthropic:default`, bu
|
||||
- **Make sure you’re editing the correct agent**
|
||||
- Multi‑agent setups mean there can be multiple `auth-profiles.json` files.
|
||||
- **Sanity‑check 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?
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -16,20 +16,30 @@ NC='\033[0m' # No Color
|
||||
# Output mode: "full" (default), "json", or "simple"
|
||||
OUTPUT_MODE="${1:-full}"
|
||||
|
||||
check_claude_code_auth() {
|
||||
if [ ! -f "$CLAUDE_CREDS" ]; then
|
||||
echo "MISSING"
|
||||
return 1
|
||||
fi
|
||||
fetch_models_status_json() {
|
||||
clawdbot models status --json 2>/dev/null || true
|
||||
}
|
||||
|
||||
local expires_at
|
||||
expires_at=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS")
|
||||
local now_ms=$(($(date +%s) * 1000))
|
||||
STATUS_JSON="$(fetch_models_status_json)"
|
||||
USE_JSON=0
|
||||
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 hours=$((diff_ms / 3600000))
|
||||
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"
|
||||
return 1
|
||||
elif [ "$diff_ms" -lt 3600000 ]; then
|
||||
@@ -41,34 +51,89 @@ check_claude_code_auth() {
|
||||
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() {
|
||||
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
|
||||
echo "MISSING"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Find the best Anthropic profile (prefer claude-cli, then any with latest expiry)
|
||||
local expires
|
||||
expires=$(jq -r '
|
||||
[.profiles | to_entries[] | select(.value.provider == "anthropic") | .value.expires]
|
||||
| max // 0
|
||||
' "$CLAWDBOT_AUTH")
|
||||
' "$CLAWDBOT_AUTH" 2>/dev/null || echo "0")
|
||||
|
||||
local now_ms=$(($(date +%s) * 1000))
|
||||
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
|
||||
calc_status_from_expires "$expires"
|
||||
}
|
||||
|
||||
# JSON output mode
|
||||
@@ -76,8 +141,15 @@ if [ "$OUTPUT_MODE" = "json" ]; then
|
||||
claude_status=$(check_claude_code_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")
|
||||
clawdbot_expires=$(jq -r '.profiles["anthropic:default"].expires // 0' "$CLAWDBOT_AUTH" 2>/dev/null || echo "0")
|
||||
claude_expires=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 \
|
||||
--arg cs "$claude_status" \
|
||||
@@ -121,19 +193,28 @@ echo ""
|
||||
|
||||
# Claude Code credentials
|
||||
echo "Claude Code (~/.claude/.credentials.json):"
|
||||
if [ -f "$CLAUDE_CREDS" ]; then
|
||||
expires_at=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS")
|
||||
sub_type=$(jq -r '.claudeAiOauth.subscriptionType // "unknown"' "$CLAUDE_CREDS")
|
||||
rate_tier=$(jq -r '.claudeAiOauth.rateLimitTier // "unknown"' "$CLAUDE_CREDS")
|
||||
if [ "$USE_JSON" -eq 1 ]; then
|
||||
expires_at=$(json_expires_for_claude_cli)
|
||||
else
|
||||
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))
|
||||
hours=$((diff_ms / 3600000))
|
||||
mins=$(((diff_ms % 3600000) / 60000))
|
||||
|
||||
echo " Subscription: $sub_type"
|
||||
echo " Rate tier: $rate_tier"
|
||||
|
||||
if [ "$diff_ms" -lt 0 ]; then
|
||||
echo -e " Status: ${RED}EXPIRED${NC}"
|
||||
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 " Expires: $(date -d @$((expires_at/1000))) (${hours}h ${mins}m)"
|
||||
fi
|
||||
else
|
||||
echo -e " Status: ${RED}NOT FOUND${NC}"
|
||||
echo " Action needed: Run 'claude setup-token'"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Clawdbot Auth (~/.clawdbot/agents/main/agent/auth-profiles.json):"
|
||||
if [ -f "$CLAWDBOT_AUTH" ]; then
|
||||
# Find best Anthropic profile
|
||||
if [ "$USE_JSON" -eq 1 ]; then
|
||||
best_profile=$(json_best_anthropic_profile)
|
||||
expires=$(json_expires_for_anthropic_any)
|
||||
api_keys=$(json_anthropic_api_key_count)
|
||||
else
|
||||
best_profile=$(jq -r '
|
||||
.profiles | to_entries
|
||||
| map(select(.value.provider == "anthropic"))
|
||||
| sort_by(.value.expires) | reverse
|
||||
| .[0].key // "none"
|
||||
' "$CLAWDBOT_AUTH")
|
||||
|
||||
' "$CLAWDBOT_AUTH" 2>/dev/null || echo "none")
|
||||
expires=$(jq -r '
|
||||
[.profiles | to_entries[] | select(.value.provider == "anthropic") | .value.expires]
|
||||
| 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))
|
||||
hours=$((diff_ms / 3600000))
|
||||
mins=$(((diff_ms % 3600000) / 60000))
|
||||
|
||||
echo " Profile: $best_profile"
|
||||
|
||||
if [ "$diff_ms" -lt 0 ]; then
|
||||
echo -e " Status: ${RED}EXPIRED${NC}"
|
||||
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 " Expires: $(date -d @$((expires/1000))) (${hours}h ${mins}m)"
|
||||
fi
|
||||
else
|
||||
echo -e " Status: ${RED}NOT FOUND${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
67
src/agents/auth-health.test.ts
Normal file
67
src/agents/auth-health.test.ts
Normal 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
227
src/agents/auth-health.ts
Normal 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 };
|
||||
}
|
||||
@@ -57,6 +57,11 @@ export function registerModelsCli(program: Command) {
|
||||
.description("Show configured model state")
|
||||
.option("--json", "Output JSON", 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) => {
|
||||
try {
|
||||
await modelsStatusCommand(opts, defaultRuntime);
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { note } from "@clack/prompts";
|
||||
|
||||
import {
|
||||
buildAuthHealthSummary,
|
||||
DEFAULT_OAUTH_WARN_MS,
|
||||
formatRemainingShort,
|
||||
} from "../agents/auth-health.js";
|
||||
import {
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
CODEX_CLI_PROFILE_ID,
|
||||
ensureAuthProfileStore,
|
||||
repairOAuthProfileIdMismatch,
|
||||
resolveApiKeyForProfile,
|
||||
} from "../agents/auth-profiles.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
@@ -28,3 +36,114 @@ export async function maybeRepairAnthropicOAuthProfileId(
|
||||
if (!apply) return cfg;
|
||||
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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ import {
|
||||
GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||
type GatewayDaemonRuntime,
|
||||
} from "./daemon-runtime.js";
|
||||
import { maybeRepairAnthropicOAuthProfileId } from "./doctor-auth.js";
|
||||
import {
|
||||
maybeRepairAnthropicOAuthProfileId,
|
||||
noteAuthProfileHealth,
|
||||
} from "./doctor-auth.js";
|
||||
import {
|
||||
buildGatewayRuntimeHints,
|
||||
formatGatewayRuntimeSummary,
|
||||
@@ -124,6 +127,12 @@ export async function doctorCommand(
|
||||
}
|
||||
|
||||
cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter);
|
||||
await noteAuthProfileHealth({
|
||||
cfg,
|
||||
prompter,
|
||||
allowKeychainPrompt:
|
||||
options.nonInteractive !== true && Boolean(process.stdin.isTTY),
|
||||
});
|
||||
const gatewayDetails = buildGatewayConnectionDetails({ config: cfg });
|
||||
if (gatewayDetails.remoteFallbackNote) {
|
||||
note(gatewayDetails.remoteFallbackNote, "Gateway");
|
||||
|
||||
@@ -77,12 +77,17 @@ vi.mock("../../agents/agent-paths.js", () => ({
|
||||
resolveClawdbotAgentDir: mocks.resolveClawdbotAgentDir,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/auth-profiles.js", () => ({
|
||||
ensureAuthProfileStore: mocks.ensureAuthProfileStore,
|
||||
listProfilesForProvider: mocks.listProfilesForProvider,
|
||||
resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel,
|
||||
resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay,
|
||||
}));
|
||||
vi.mock("../../agents/auth-profiles.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../agents/auth-profiles.js")>();
|
||||
return {
|
||||
...actual,
|
||||
ensureAuthProfileStore: mocks.ensureAuthProfileStore,
|
||||
listProfilesForProvider: mocks.listProfilesForProvider,
|
||||
resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel,
|
||||
resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../agents/model-auth.js", () => ({
|
||||
resolveEnvApiKey: mocks.resolveEnvApiKey,
|
||||
@@ -126,6 +131,9 @@ describe("modelsStatusCommand auth overview", () => {
|
||||
expect(payload.auth.shellEnvFallback.appliedKeys).toContain(
|
||||
"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<{
|
||||
provider: string;
|
||||
@@ -152,4 +160,27 @@ describe("modelsStatusCommand auth overview", () => {
|
||||
),
|
||||
).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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
|
||||
import {
|
||||
buildAuthHealthSummary,
|
||||
DEFAULT_OAUTH_WARN_MS,
|
||||
formatRemainingShort,
|
||||
} from "../../agents/auth-health.js";
|
||||
import {
|
||||
type AuthProfileStore,
|
||||
ensureAuthProfileStore,
|
||||
@@ -599,7 +604,7 @@ export async function modelsListCommand(
|
||||
}
|
||||
|
||||
export async function modelsStatusCommand(
|
||||
opts: { json?: boolean; plain?: boolean },
|
||||
opts: { json?: boolean; plain?: boolean; check?: boolean },
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
ensureFlagCompatibility(opts);
|
||||
@@ -656,6 +661,7 @@ export async function modelsStatusCommand(
|
||||
.filter(Boolean),
|
||||
);
|
||||
const providersFromModels = new Set<string>();
|
||||
const providersInUse = new Set<string>();
|
||||
for (const raw of [
|
||||
defaultLabel,
|
||||
...fallbacks,
|
||||
@@ -666,6 +672,15 @@ export async function modelsStatusCommand(
|
||||
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_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>();
|
||||
// Keep in sync with resolveEnvApiKey() mappings (we want visibility even when
|
||||
@@ -715,6 +730,12 @@ export async function modelsStatusCommand(
|
||||
Boolean(entry.modelsJson);
|
||||
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
|
||||
.filter(
|
||||
@@ -726,6 +747,29 @@ export async function modelsStatusCommand(
|
||||
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) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
@@ -746,18 +790,30 @@ export async function modelsStatusCommand(
|
||||
appliedKeys: applied,
|
||||
},
|
||||
providersWithOAuth: providersWithOauth,
|
||||
missingProvidersInUse,
|
||||
providers: providerAuth,
|
||||
oauth: {
|
||||
warnAfterMs: authHealth.warnAfterMs,
|
||||
profiles: authHealth.profiles,
|
||||
providers: authHealth.providers,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
if (opts.check) {
|
||||
runtime.exit(checkStatus);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.plain) {
|
||||
runtime.log(resolvedLabel);
|
||||
if (opts.check) {
|
||||
runtime.exit(checkStatus);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -933,4 +989,48 @@ export async function modelsStatusCommand(
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user