nix-hermes-agent/docs/AUTH-ANALYSIS.md
Ciphercat e7b70a7b4d feat: persistent auth — seed-once, never overwrite on rebuild
- authFile now only copies to auth.json if it doesn't already exist
- Runtime token refreshes (OAuth rotate) survive nixos-rebuild
- Added authFileForceOverwrite option (default false) as escape hatch
- Committed AUTH-ANALYSIS.md documenting full auth architecture
2026-03-16 11:45:02 +00:00

650 lines
30 KiB
Markdown

# Hermes Agent Authentication System Analysis
**Date:** 2026-03-16
**Source:** `/nix/store/3nz25qic608jccip6a6m49da24dxibi8-hermes-agent-0.2.0`
**Version:** 0.2.0
---
## 1. Auth Architecture Overview
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ HERMES AUTH RESOLUTION FLOW │
└─────────────────────────────────────────────────────────────────────────────┘
┌──────────────┐
│ CLI Call │
│ hermes chat │
└──────┬───────┘
┌────────────────────────────────┐
│ resolve_runtime_provider() │
│ (runtime_provider.py) │
└────────────────┬───────────────┘
┌────────────────┴────────────────┐
│ │
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ Config-based provider │ │ resolve_provider() │
│ (config.yaml) │ │ (auth.py) │
└───────────┬───────────┘ └───────────┬───────────┘
│ │
│ ┌───────────────────────┼───────────────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ PROVIDER RESOLUTION PRIORITY │
├─────────────────────────────────────────────────────────────────────────┤
│ 1. explicit --api-key / --base-url CLI args → openrouter │
│ 2. active_provider in ~/.hermes/auth.json (OAuth providers) │
│ 3. OPENAI_API_KEY / OPENROUTER_API_KEY env vars → openrouter │
│ 4. Provider-specific env vars (GLM_API_KEY, KIMI_API_KEY, etc.) │
│ 5. Fallback → openrouter │
└─────────────────────────────────────────────────────────────────────────┘
┌───────────────────────────┼───────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ OAUTH PROVIDERS │ │ API-KEY PROVIDERS│ │ OPENROUTER │
│ (auth.json) │ │ (env vars) │ │ (default) │
├─────────────────┤ ├─────────────────┤ ├─────────────────┤
│ • nous │ │ • zai (GLM) │ │ OPENROUTER_API_ │
│ • openai-codex │ │ • kimi-coding │ │ KEY or │
│ │ │ • minimax │ │ OPENAI_API_KEY │
│ Device Code → │ │ • minimax-cn │ │ │
│ access_token │ │ • anthropic │ │ OpenRouter API │
│ + refresh_token │ │ │ │ or custom URL │
│ + agent_key │ │ Direct env vars │ │ via OPENAI_BASE │
│ │ │ → runtime creds │ │ _URL │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ RUNTIME CREDENTIALS │
│ { │
│ "provider": "nous" | "openai-codex" | "zai" | ... | "openrouter", │
│ "api_mode": "chat_completions" | "anthropic_messages" | "codex_responses",│
│ "base_url": "https://...", │
│ "api_key": "...", │
│ "source": "portal" | "env" | "hermes-auth-store", │
│ "expires_at": "ISO timestamp" (OAuth only), │
│ } │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## 2. auth.json Schema
**Location:** `~/.hermes/auth.json`
**Version:** 1
```json
{
"version": 1,
"active_provider": "nous" | "openai-codex" | null,
"providers": {
"nous": {
// OAuth device code flow tokens
"portal_base_url": "https://portal.nousresearch.com",
"inference_base_url": "https://inference-api.nousresearch.com/v1",
"client_id": "hermes-cli",
"scope": "inference:mint_agent_key",
"token_type": "Bearer",
// Core OAuth tokens
"access_token": "eyJ...",
"refresh_token": "rt_...",
"obtained_at": "2026-03-16T00:00:00+00:00",
"expires_at": "2026-03-16T01:00:00+00:00",
"expires_in": 3600,
// Minted agent key (short-lived inference key)
"agent_key": "ak_...",
"agent_key_id": "key-uuid",
"agent_key_expires_at": "2026-03-16T00:30:00+00:00",
"agent_key_expires_in": 1800,
"agent_key_reused": false,
"agent_key_obtained_at": "2026-03-16T00:00:00+00:00",
// TLS config
"tls": {
"insecure": false,
"ca_bundle": null
}
},
"openai-codex": {
// Codex OAuth tokens (stored in Hermes, not ~/.codex/)
"tokens": {
"access_token": "eyJ...",
"refresh_token": "rt_..."
},
"last_refresh": "2026-03-16T00:00:00Z",
"auth_mode": "chatgpt"
}
},
"updated_at": "2026-03-16T00:00:00+00:00"
}
```
### Key Points:
- **Cross-process locking**: Uses file-based advisory lock (`auth.json.lock`) with 15s timeout
- **Atomic writes**: Writes to temp file, then `os.replace()` for crash safety
- **Permission restricted**: `chmod 0600` (owner read/write only)
- **Version field**: Schema version for future migrations
---
## 3. Per-Provider Auth Flow
### 3.1 Nous Portal (OAuth Device Code)
**Provider ID:** `nous`
**Auth Type:** `oauth_device_code`
```
┌─────────────────────────────────────────────────────────────────────────┐
│ NOUS PORTAL OAUTH FLOW │
└─────────────────────────────────────────────────────────────────────────┘
User runs: hermes login --provider nous
┌────────────────────────────────────────┐
│ 1. POST /api/oauth/device/code │
│ → device_code, user_code, │
│ verification_uri_complete │
└───────────────────┬────────────────────┘
┌────────────────────────────────────────┐
│ 2. User opens URL, enters code │
│ Browser → Portal login → Authorize │
└───────────────────┬────────────────────┘
┌────────────────────────────────────────┐
│ 3. Poll /api/oauth/token │
│ (device_code grant) │
│ → access_token, refresh_token │
└───────────────────┬────────────────────┘
┌────────────────────────────────────────┐
│ 4. Mint agent key │
│ POST /api/oauth/agent-key │
│ Authorization: Bearer access_token │
│ → api_key (short-lived, 30min TTL) │
└───────────────────┬────────────────────┘
┌────────────────────────────────────────┐
│ 5. Store to ~/.hermes/auth.json │
│ Set active_provider = "nous" │
└────────────────────────────────────────┘
```
**Token Refresh:**
- Access token auto-refreshed when `expires_at < now + 120s`
- Agent key auto-re-minted when `agent_key_expires_at < now + 30min`
- Refresh uses `refresh_token` grant to `/api/oauth/token`
- If refresh fails with `invalid_grant`, user must re-login
**Code References:**
- `auth.py:1413-1505``_request_device_code()`, `_poll_for_token()`
- `auth.py:1507-1560``_refresh_access_token()`, `_mint_agent_key()`
- `auth.py:1625-1780``resolve_nous_runtime_credentials()`
---
### 3.2 OpenAI Codex (OAuth External)
**Provider ID:** `openai-codex`
**Auth Type:** `oauth_external`
```
┌─────────────────────────────────────────────────────────────────────────┐
│ CODEX OAUTH FLOW │
└─────────────────────────────────────────────────────────────────────────┘
User runs: hermes login --provider openai-codex
┌────────────────────────────────────────┐
│ 1. POST /api/accounts/deviceauth/usercode│
│ client_id: app_EMoamEEZ73f0CkXaXp7hrann│
│ → user_code, device_auth_id │
└───────────────────┬────────────────────┘
┌────────────────────────────────────────┐
│ 2. User opens auth.openai.com/codex/device│
│ Enters user_code │
└───────────────────┬────────────────────┘
┌────────────────────────────────────────┐
│ 3. Poll /api/accounts/deviceauth/token │
│ → authorization_code, code_verifier │
└───────────────────┬────────────────────┘
┌────────────────────────────────────────┐
│ 4. Exchange for tokens │
│ POST /oauth/token │
│ → access_token (JWT), refresh_token │
└───────────────────┬────────────────────┘
┌────────────────────────────────────────┐
│ 5. Store to ~/.hermes/auth.json │
│ (NOT ~/.codex/auth.json) │
│ Hermes maintains its own session │
└────────────────────────────────────────┘
```
**Important Notes:**
- Hermes stores Codex tokens in its **own** auth store, not `~/.codex/`
- This avoids token rotation conflicts with Codex CLI / VS Code
- On first run, Hermes can **migrate** tokens from `~/.codex/auth.json`
- Access token is a JWT; expiry checked via `exp` claim
**Code References:**
- `auth.py:840-1010``_codex_device_code_login()`, `_refresh_codex_auth_tokens()`
- `auth.py:1015-1085``resolve_codex_runtime_credentials()`
- `codex_models.py` — Model discovery from Codex API
---
### 3.3 Z.AI / GLM (API Key)
**Provider ID:** `zai`
**Auth Type:** `api_key`
**Env Vars (checked in order):**
1. `GLM_API_KEY`
2. `ZAI_API_KEY`
3. `Z_AI_API_KEY`
**Base URL Override:** `GLM_BASE_URL`
**Endpoint Auto-Detection:**
Hermes probes multiple Z.AI endpoints to find one that accepts the API key:
| Endpoint ID | Base URL | Default Model |
|------------------|---------------------------------------------------|---------------|
| `global` | `https://api.z.ai/api/paas/v4` | `glm-5` |
| `cn` | `https://open.bigmodel.cn/api/paas/v4` | `glm-5` |
| `coding-global` | `https://api.z.ai/api/coding/paas/v4` | `glm-4.7` |
| `coding-cn` | `https://open.bigmodel.cn/api/coding/paas/v4` | `glm-4.7` |
**Code References:**
- `auth.py:350-390``detect_zai_endpoint()`
- `auth.py:160-175` — Provider registry entry
---
### 3.4 Kimi / Moonshot (API Key)
**Provider ID:** `kimi-coding`
**Auth Type:** `api_key`
**Env Var:** `KIMI_API_KEY`
**Base URL Auto-Detection:**
| Key Prefix | Base URL |
|---------------|----------------------------------|
| `sk-kimi-` | `https://api.kimi.com/coding/v1` |
| (default) | `https://api.moonshot.ai/v1` |
**Override:** `KIMI_BASE_URL`
**Code References:**
- `auth.py:305-325``_resolve_kimi_base_url()`
- `auth.py:176-183` — Provider registry entry
---
### 3.5 MiniMax (API Key)
**Provider ID:** `minimax` (international) or `minimax-cn` (China)
**Auth Type:** `api_key`
| Provider | Env Var | Base URL |
|---------------|--------------------|---------------------------------|
| `minimax` | `MINIMAX_API_KEY` | `https://api.minimax.io/v1` |
| `minimax-cn` | `MINIMAX_CN_API_KEY`| `https://api.minimaxi.com/v1` |
**Override:** `MINIMAX_BASE_URL` / `MINIMAX_CN_BASE_URL`
**Code References:**
- `auth.py:184-197` — Provider registry entries
---
### 3.6 Anthropic (API Key / OAuth)
**Provider ID:** `anthropic`
**Auth Type:** `api_key` (with OAuth support)
**Resolution Priority:**
```
1. ANTHROPIC_TOKEN env var
2. CLAUDE_CODE_OAUTH_TOKEN env var
3. ~/.claude/.credentials.json (claudeAiOauth.accessToken)
└── Auto-refresh if expired + refreshToken available
4. ANTHROPIC_API_KEY env var
```
**Token Types:**
| Prefix | Auth Method | Headers |
|-----------------|-----------------------|----------------------------------------|
| `sk-ant-api*` | API key (x-api-key) | `x-api-key: <token>` |
| `sk-ant-oat*` | OAuth/setup token | `Authorization: Bearer <token>` |
| (JWT/other) | Bearer auth | `Authorization: Bearer <token>` |
**Beta Headers:**
- All requests: `interleaved-thinking-2025-05-14`, `fine-grained-tool-streaming-2025-05-14`
- OAuth only: `claude-code-20250219`, `oauth-2025-04-20`
**Code References:**
- `agent/anthropic_adapter.py:1-100` — Token type detection, client building
- `agent/anthropic_adapter.py:101-260` — Claude Code credential resolution, refresh
- `agent/anthropic_adapter.py:261-310``resolve_anthropic_token()` priority chain
---
### 3.7 OpenRouter / Custom (API Key)
**Provider ID:** `openrouter` or `custom`
**Auth Type:** `api_key` (fallback)
**Env Vars:**
- `OPENROUTER_API_KEY` (preferred for OpenRouter)
- `OPENAI_API_KEY` (fallback, or for custom endpoints)
**Base URL:**
- Default: `https://openrouter.ai/api/v1`
- Override: `OPENAI_BASE_URL` or `OPENROUTER_BASE_URL`
**Smart Key Selection:**
- If URL contains `openrouter.ai` → prefer `OPENROUTER_API_KEY`
- If custom URL → prefer `OPENAI_API_KEY`
**Custom Providers:**
Users can define custom providers in `config.yaml`:
```yaml
custom_providers:
- name: "local-llm"
base_url: "http://localhost:11434/v1"
api_key: "" # optional
```
Then use with: `hermes chat --provider custom:local-llm`
**Code References:**
- `runtime_provider.py:55-115``_resolve_openrouter_runtime()`, `_resolve_named_custom_runtime()`
- `auth.py:775-830``resolve_provider()` priority chain
---
## 4. Token Lifecycle
### 4.1 Storage Locations
| Secret Type | Location | Permissions |
|-----------------------|-------------------------------|-------------|
| OAuth tokens | `~/.hermes/auth.json` | `0600` |
| API keys | `~/.hermes/.env` | `0600` |
| Claude Code OAuth | `~/.claude/.credentials.json` | `0600` |
| Codex CLI OAuth | `~/.codex/auth.json` | (external) |
### 4.2 Token Refresh
```
┌─────────────────────────────────────────────────────────────────────────┐
│ TOKEN REFRESH MECHANISMS │
└─────────────────────────────────────────────────────────────────────────┘
Provider │ Refresh Mechanism │ Trigger
─────────────────┼────────────────────────────────┼─────────────────────
Nous Portal │ refresh_token grant │ expires_at < now+2m
│ → new access_token │
│ → may rotate refresh_token │
─────────────────┼────────────────────────────────┼─────────────────────
Nous Agent Key │ Re-mint via agent-key endpoint │ expires_at < now+30m
│ (uses valid access_token) │
─────────────────┼────────────────────────────────┼─────────────────────
Codex │ refresh_token grant │ JWT exp < now+2m
│ → new access_token (JWT) │
│ → may rotate refresh_token │
─────────────────┼────────────────────────────────┼─────────────────────
Anthropic OAuth │ Claude Code refresh endpoint │ expiresAt < now
│ (only if refreshToken exists) │
─────────────────┼────────────────────────────────┼─────────────────────
API Key providers│ None (static keys) │ N/A
```
### 4.3 Expiry Handling
**Nous Portal:**
- Access token: ~1 hour TTL, refresh 2 minutes before expiry
- Agent key: 30+ minute TTL, re-mint 30 minutes before expiry
- On refresh failure with `invalid_grant`: `relogin_required=True`
**Codex:**
- Access token: JWT with `exp` claim, refresh 2 minutes before expiry
- On refresh failure with `invalid_grant`/`invalid_token`: `relogin_required=True`
**Anthropic (Claude Code):**
- `expiresAt` in milliseconds since epoch
- Refresh via `https://console.anthropic.com/v1/oauth/token`
- Client ID: `9d1c250a-e61b-44d9-88ed-5944d1962f5e`
### 4.4 Error Handling
```python
class AuthError(RuntimeError):
message: str
provider: str
code: Optional[str] # "invalid_grant", "subscription_required", etc.
relogin_required: bool
```
**Common Error Codes:**
| Code | Meaning | Action |
|--------------------------|---------------------------------|---------------------|
| `invalid_grant` | Refresh token expired/revoked | Re-login required |
| `invalid_token` | Access token invalid | Refresh or re-login |
| `subscription_required` | No Nous subscription | User action needed |
| `insufficient_credits` | Credits exhausted | User action needed |
| `temporarily_unavailable`| Rate limited | Retry later |
---
## 5. Code References
### Core Auth Files
| File | Purpose | Key Functions |
|-----------------------------------|-----------------------------------------------|---------------|
| `hermes_cli/auth.py` | Main auth system (79KB) | `resolve_provider()`, `resolve_nous_runtime_credentials()`, `resolve_codex_runtime_credentials()`, `login_command()`, `logout_command()` |
| `hermes_cli/runtime_provider.py` | Runtime credential resolution | `resolve_runtime_provider()`, `_resolve_openrouter_runtime()` |
| `hermes_cli/config.py` | Config + env management | `save_env_value()`, `get_env_value()`, `load_config()` |
| `hermes_cli/models.py` | Model catalogs + provider:model parsing | `parse_model_input()`, `provider_model_ids()` |
| `hermes_cli/main.py` | CLI entry point | `_model_flow_nous()`, `_model_flow_codex()`, `_model_flow_api_key_provider()` |
| `agent/anthropic_adapter.py` | Anthropic-specific auth + API adapter | `resolve_anthropic_token()`, `build_anthropic_client()` |
| `acp_adapter/auth.py` | ACP server provider detection | `detect_provider()` |
### Key Functions by Line
**auth.py:**
```
L100-200 ProviderConfig dataclass, PROVIDER_REGISTRY
L265-350 Auth store persistence (_load_auth_store, _save_auth_store)
L380-420 Provider resolution (resolve_provider)
L500-600 OAuth device code flow (_request_device_code, _poll_for_token)
L605-700 Nous token refresh + agent key minting
L715-780 Nous runtime credential resolution
L840-1010 Codex OAuth flow + token refresh
L1015-1085 Codex runtime credential resolution
L1090-1160 API key provider resolution
L1170-1260 Status helpers (get_auth_status)
L1340-1500 Login commands (_login_nous, _login_openai_codex)
L1625-1780 resolve_nous_runtime_credentials
```
**runtime_provider.py:**
```
L25-55 resolve_requested_provider()
L58-100 Custom provider resolution
L105-145 OpenRouter/custom runtime resolution
L150-230 resolve_runtime_provider() (main entry)
```
**anthropic_adapter.py:**
```
L35-80 Token type detection (_is_oauth_token)
L85-120 Claude Code credential reading
L125-180 Token refresh logic
L185-230 resolve_anthropic_token() priority chain
L265-340 OAuth setup-token flow
```
---
## 6. Implications for Nix
### 6.1 What Can Be Declarative
| Component | Declarative? | Notes |
|--------------------|--------------|------------------------------------------------|
| Config structure | ✅ Yes | `config.yaml` can be generated |
| Default model | ✅ Yes | Set in config.yaml |
| Toolsets | ✅ Yes | List in config.yaml |
| Custom providers | ✅ Yes | Define in config.yaml |
| Display settings | ✅ Yes | All in config.yaml |
| Terminal backend | ✅ Yes | local/docker/singularity/ssh |
### 6.2 What Requires Runtime Interaction
| Component | Interactive? | Why |
|--------------------------|--------------|----------------------------------------------|
| Nous Portal OAuth | ✅ Yes | Requires browser-based device code auth |
| Codex OAuth | ✅ Yes | Requires browser-based device code auth |
| Anthropic OAuth | ✅ Yes | Requires `claude setup-token` or browser |
| API Keys (first-time) | ⚠️ Semi | Can be env vars, but setup wizard helps |
| Model selection | ⚠️ Semi | Can be declarative, but wizard discovers live models |
### 6.3 Nix Module Design Recommendations
```nix
# Example NixOS module options
services.hermes = {
enable = true;
# Declarative config
config = {
model = {
default = "anthropic/claude-opus-4.6";
provider = "auto"; # or "nous", "openrouter", "zai", etc.
};
terminal = {
backend = "local";
timeout = 180;
};
display = {
compact = true;
personality = "kawaii";
};
};
# Environment variables (secrets)
environmentFile = "/run/secrets/hermes.env";
# OR individual secrets via sops-nix, agenix, etc.
# Custom providers
customProviders = [
{
name = "local-vllm";
baseUrl = "http://localhost:8000/v1";
}
];
};
# Secrets file format (hermes.env):
# OPENROUTER_API_KEY=sk-or-...
# GLM_API_KEY=...
# ANTHROPIC_API_KEY=sk-ant-api-...
```
### 6.4 Auth State Management
**For Nix deployments:**
1. **API Key Providers** (recommended for servers):
- Use `environmentFile` with secrets management
- Keys: `OPENROUTER_API_KEY`, `GLM_API_KEY`, `ANTHROPIC_API_KEY`, etc.
- No interactive auth required
2. **OAuth Providers** (not recommended for headless):
- `auth.json` must be provisioned after first login
- Could use `systemd-tmpfiles` to pre-seed, but tokens expire
- Better: use API key providers for automated setups
3. **Anthropic via Claude Code**:
- Can use `~/.claude/.credentials.json` if pre-provisioned
- Or set `ANTHROPIC_API_KEY` for API key mode
### 6.5 File Locations for Nix
```nix
# Hermes home directory
environment.variables.HERMES_HOME = "/var/lib/hermes";
# Or per-user
users.users.myuser.home = "/home/myuser";
# HERMES_HOME defaults to ~/.hermes
```
**Required directories:**
- `$HERMES_HOME/` (root)
- `$HERMES_HOME/cron/`
- `$HERMES_HOME/sessions/`
- `$HERMES_HOME/logs/`
- `$HERMES_HOME/memories/`
---
## 7. Summary
Hermes uses a sophisticated multi-provider auth system with:
1. **OAuth Device Code Flow** for Nous Portal and Codex — interactive browser-based auth with automatic token refresh
2. **API Key Resolution** for OpenRouter, Z.AI, Kimi, MiniMax, Anthropic — environment variable based with smart fallbacks
3. **Unified Credential Resolution** via `resolve_runtime_provider()` — single entry point for all providers
4. **Cross-Process Safety** with file locking and atomic writes to `auth.json`
5. **Automatic Token Refresh** for OAuth providers with configurable skew
6. **Graceful Error Handling** with user-friendly messages and `relogin_required` hints
For Nix packaging:
- API key providers are fully declarative
- OAuth providers require one-time interactive login
- Config can be fully declarative via YAML
- Secrets should use Nix secrets management (sops-nix, agenix, etc.)