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

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_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-1780resolve_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-1085resolve_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-390detect_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-310resolve_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:

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-830resolve_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

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:

  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

# 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.)