- 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
30 KiB
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
{
"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_tokengrant 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
expclaim
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):
GLM_API_KEYZAI_API_KEYZ_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 buildingagent/anthropic_adapter.py:101-260— Claude Code credential resolution, refreshagent/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_URLorOPENROUTER_BASE_URL
Smart Key Selection:
- If URL contains
openrouter.ai→ preferOPENROUTER_API_KEY - If custom URL → prefer
OPENAI_API_KEY
Custom Providers:
Users can define custom providers in config.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
expclaim, refresh 2 minutes before expiry - On refresh failure with
invalid_grant/invalid_token:relogin_required=True
Anthropic (Claude Code):
expiresAtin milliseconds since epoch- Refresh via
https://console.anthropic.com/v1/oauth/token - Client ID:
9d1c250a-e61b-44d9-88ed-5944d1962f5e
4.4 Error Handling
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
# 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:
-
API Key Providers (recommended for servers):
- Use
environmentFilewith secrets management - Keys:
OPENROUTER_API_KEY,GLM_API_KEY,ANTHROPIC_API_KEY, etc. - No interactive auth required
- Use
-
OAuth Providers (not recommended for headless):
auth.jsonmust be provisioned after first login- Could use
systemd-tmpfilesto pre-seed, but tokens expire - Better: use API key providers for automated setups
-
Anthropic via Claude Code:
- Can use
~/.claude/.credentials.jsonif pre-provisioned - Or set
ANTHROPIC_API_KEYfor API key mode
- Can use
6.5 File Locations for 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:
- OAuth Device Code Flow for Nous Portal and Codex — interactive browser-based auth with automatic token refresh
- API Key Resolution for OpenRouter, Z.AI, Kimi, MiniMax, Anthropic — environment variable based with smart fallbacks
- Unified Credential Resolution via
resolve_runtime_provider()— single entry point for all providers - Cross-Process Safety with file locking and atomic writes to
auth.json - Automatic Token Refresh for OAuth providers with configurable skew
- Graceful Error Handling with user-friendly messages and
relogin_requiredhints
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.)