From 2e3e5e5e59c39da7fbcbe4cc621b2467c59471ca Mon Sep 17 00:00:00 2001 From: "Zachariah K. Sharma" Date: Sat, 6 Jun 2026 02:15:41 -0600 Subject: [PATCH] docs: document managed hermes api access Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + README.md | 148 +++++++++++++++++++++++++++++++++++++++------ test/e2e-smoke.cjs | 100 ++++++++++++++++++++++++++++++ 3 files changed, 229 insertions(+), 20 deletions(-) create mode 100644 test/e2e-smoke.cjs diff --git a/.gitignore b/.gitignore index 7f3c6c3..30c8373 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ coverage/ node_modules/ state/ +.superpowers/ diff --git a/README.md b/README.md index 0e43602..7754b6e 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,46 @@ # Hermes Control Plane -A local, single-page operations console for Hermes Agent. The app is a small zero-dependency Node.js server that serves the UI and shells out to a Hermes CLI installation. +A local operations console and authenticated AI API gateway for Hermes Agent. Includes admin authentication, per-user API key management, pre/post AI API gateways, and full audit logging. ## What It Does -- Lists OAuth/API-key provider pools. -- Displays account identity/email data when it can be derived from Codex, Claude, Gemini, or Hermes JWT-backed auth state. +- Admin-authenticated control plane at port 7843 (login required). +- Per-user API key management: create, rotate, revoke, delete keys with rate limits and monthly token caps. +- Pre-Hermes AI API gateway at port 8645 — authenticated proxy for requests sent before Hermes processing. +- Post-Hermes AI API gateway at port 8646 — authenticated proxy for requests sent after Hermes processing. +- Full audit logging of prompts and responses with 90-day retention and JSONL download. +- Lists OAuth/API-key provider pools and account identity data from Codex, Claude, Gemini, and Hermes auth state. - Edits Hermes model/fallback configuration. - Provides panes for skills, plugins, bundles, MCP, cron, sessions, hooks, memory, kanban, webhooks, profiles, storage, config, tools, insights, security, and system status. +## Prerequisites + +- Docker and Docker Compose, or Node.js >= 20 for local runs. +- PostgreSQL database for admin auth, API key storage, and audit logging. + +## PostgreSQL Setup + +Create the database and a least-privilege user: + +```sql +CREATE DATABASE hermes_control_plane; +CREATE USER hermes_user WITH PASSWORD 'choose-a-strong-password'; +GRANT ALL ON DATABASE hermes_control_plane TO hermes_user; +``` + +Migrations run automatically on startup. No manual schema setup is needed beyond creating the database and user. + +Set the connection string in your environment: + +```text +DATABASE_URL=postgres://hermes_user:password@localhost:5432/hermes_control_plane +``` + ## Run With Docker Compose ```bash cp .env.example .env +# Edit .env with your values, then: docker compose up --build -d docker compose logs -f ``` @@ -20,11 +48,80 @@ docker compose logs -f Open: ```text -Control plane: http://127.0.0.1:7843 -Pre-Hermes AI API: http://127.0.0.1:8645/v1 -Post-Hermes AI API: http://127.0.0.1:8646/v1 +Admin login: http://127.0.0.1:7843/login +Control plane: http://127.0.0.1:7843 +Pre-Hermes AI API: http://127.0.0.1:8645/v1/chat/completions +Post-Hermes AI API: http://127.0.0.1:8646/v1/chat/completions ``` +Required environment variables for auth and gateway features: + +```text +DATABASE_URL PostgreSQL connection string +HERMES_ADMIN_USERNAME Admin username (default: admin) +HERMES_ADMIN_PASSWORD Admin password — minimum 16 characters +HERMES_ADMIN_SESSION_TTL_HOURS Session lifetime in hours (default: 8) +HERMES_LOG_RETENTION_DAYS Audit log retention in days (default: 90) +HERMES_AUDIT_MAX_BYTES Max bytes per logged request body (default: 65536) +``` + +## Admin Login + +Navigate to `http://:7843/login` and sign in with `HERMES_ADMIN_USERNAME` and `HERMES_ADMIN_PASSWORD`. + +The admin password must be at least 16 characters. Sessions expire after `HERMES_ADMIN_SESSION_TTL_HOURS` hours (default 8). + +On first run, set your admin credentials in the environment before starting the container. There is no in-app registration flow. + +## API Access (Pre/Post) + +API keys start with `hms_`. Each key is scoped to allow the pre gateway, the post gateway, or both. + +- **Pre API** (`http://:8645/v1/chat/completions`) — for requests sent before Hermes processing. +- **Post API** (`http://:8646/v1/chat/completions`) — for requests sent after Hermes processing. + +Send requests with a Bearer token: + +```http +POST /v1/chat/completions +Authorization: Bearer hms_... +Content-Type: application/json +``` + +A key not authorized for a gateway returns `403 Forbidden`. A revoked or rotated key returns `410 Gone`. + +## API Key Management + +From the admin UI, navigate to **API Users** to: + +- Create a new API user with a display name, gateway access (pre/post), requests-per-minute limit, and monthly token cap. +- Rotate a key — the old key is immediately invalidated and a new `hms_` key is issued. +- Revoke a key — sets the key inactive without deleting the user record. +- Delete a user — removes the user and their key permanently. + +The plaintext key is shown only once at creation or rotation. Store it immediately. + +## Audit Logs + +Every request proxied through the pre or post gateway is logged, including the full request body and upstream response body. Logs are retained for `HERMES_LOG_RETENTION_DAYS` days (default 90). Per-entry body size is capped at `HERMES_AUDIT_MAX_BYTES` bytes (default 64 KB). + +Download logs as JSONL from the admin UI or directly: + +```http +GET /api/admin/logs/download?api_user_id= +Cookie: hermes_session=... +``` + +Response content-type is `application/x-ndjson`. Each line is a JSON object with request metadata, prompt, and response. + +## Security Operations + +- Admin password minimum: 16 characters. +- Session TTL: configurable via `HERMES_ADMIN_SESSION_TTL_HOURS` (default 8 hours). +- Rotate API keys regularly. Old keys are invalidated immediately on rotation. +- Revoke keys for users who no longer need access. +- Monitor audit logs for unexpected usage patterns. + ## Deploy With Portainer Git Stack Use Portainer's Git-backed Stack flow: @@ -32,12 +129,12 @@ Use Portainer's Git-backed Stack flow: 1. Push this repo to Git. 2. In Portainer, create a Stack from a Git repository. 3. Set the Compose path to `docker-compose.yml`. -4. Add the environment variables from `.env.example`, adjusted for your server. +4. Add the environment variables from `.env.example`, adjusted for your server. Include all required variables listed in the **Run With Docker Compose** section above. 5. Enable Portainer's auto-update option, either polling or webhook. -Portainer pulls the latest Git version when it redeploys the Stack. A normal Docker container restart does not pull Git or rebuild the image by itself, so use Portainer's webhook/polling redeploy for updates. +Portainer pulls the latest Git version when it redeploys the Stack. A normal Docker container restart does not pull Git or rebuild the image, so use Portainer's webhook/polling redeploy for updates. -`HERMES_CONTAINER_USER` defaults to `0:0` so the container can write credentials/config into Portainer-created bind directories. If you pre-create the host directories and `chown` them to UID/GID `1000:1000`, you can set `HERMES_CONTAINER_USER=1000:1000`. +`HERMES_CONTAINER_USER` defaults to `0:0` so the container can write credentials/config into Portainer-created bind directories. If you pre-create the host directories and `chown` them to UID/GID `1000:1000`, set `HERMES_CONTAINER_USER=1000:1000`. Recommended persistent host layout on the Portainer host: @@ -65,26 +162,37 @@ HERMES_POST_AI_PROVIDER=nous Run `hermes proxy providers` in the Hermes environment to see supported provider names. This Hermes build reports `nous` and `xai`. -Copy your Codex auth files into: +## Backup -```text -/opt/hermes-control-plane/codex/auth.json -/opt/hermes-control-plane/codex/authEmma.json -/opt/hermes-control-plane/codex/authMom.json +### PostgreSQL + +Back up the control plane database regularly with `pg_dump`: + +```bash +pg_dump -U hermes_user hermes_control_plane > hermes_control_plane_$(date +%Y%m%d).sql ``` -Copy Claude OAuth/auth state into: +This captures all admin sessions, API user records, and audit logs. Restore with `psql`. + +### Hermes / Codex / Claude / Gemini State + +Back up the bind-mounted directories on the host: ```text +/opt/hermes-control-plane/hermes +/opt/hermes-control-plane/codex /opt/hermes-control-plane/claude -``` - -Copy Gemini OAuth/config state into: - -```text /opt/hermes-control-plane/gemini ``` +These contain OAuth tokens, auth state, and agent configuration. Archive or snapshot them with your preferred backup method. + +## Warning: Privacy + +Full request prompts and upstream responses are stored in the audit log for up to 90 days. This includes all content sent to and received from the AI provider through the pre and post gateways. + +**Operators must inform users that their prompts and responses are logged.** Do not deploy this system for users who have not been notified of and consented to this logging. + ## Runtime Requirements The container is Linux. It can only execute a Linux-compatible Hermes install at: diff --git a/test/e2e-smoke.cjs b/test/e2e-smoke.cjs new file mode 100644 index 0000000..d1edc1b --- /dev/null +++ b/test/e2e-smoke.cjs @@ -0,0 +1,100 @@ +"use strict" + +const test = require("node:test") +const assert = require("node:assert/strict") + +const BASE_URL = process.env.HERMES_E2E_URL || "http://127.0.0.1:7843" +const ADMIN_USERNAME = process.env.HERMES_ADMIN_USERNAME || "admin" +const ADMIN_PASSWORD = process.env.HERMES_ADMIN_PASSWORD || "" +const PRE_API_URL = process.env.HERMES_PRE_E2E_URL || "http://127.0.0.1:8645" +const POST_API_URL = process.env.HERMES_POST_E2E_URL || "http://127.0.0.1:8646" + +test("e2e smoke test — full flow", async (t) => { + if (!process.env.RUN_E2E) { + t.skip("RUN_E2E not set — skipping e2e smoke test") + return + } + + // Step 1: Login to control plane + const loginRes = await fetch(`${BASE_URL}/api/admin/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: ADMIN_USERNAME, password: ADMIN_PASSWORD }) + }) + assert.equal(loginRes.status, 200, "login should succeed") + + const cookieHeader = loginRes.headers.get("set-cookie") + assert.ok(cookieHeader, "should set session cookie") + const sessionCookie = cookieHeader.split(";")[0] + + // Step 2: Create a pre-only API user + const createRes = await fetch(`${BASE_URL}/api/admin/api-users`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: sessionCookie }, + body: JSON.stringify({ + displayName: "E2E Test User", + allowPre: true, + allowPost: false, + requestsPerMinute: 60, + monthlyTokenLimit: 1000000 + }) + }) + assert.equal(createRes.status, 200, "create API user should succeed") + const { user, plaintextKey } = await createRes.json() + assert.ok(plaintextKey.startsWith("hms_"), "key should start with hms_") + + // Step 3: Call pre API successfully + const preRes = await fetch(`${PRE_API_URL}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${plaintextKey}` + }, + body: JSON.stringify({ model: "test", messages: [{ role: "user", content: "hello" }] }) + }) + assert.ok(preRes.status < 500, "pre API call should not return 5xx (upstream may fail but gateway should respond)") + + // Step 4: Call post API and receive 403 + const postRes = await fetch(`${POST_API_URL}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${plaintextKey}` + }, + body: JSON.stringify({ model: "test", messages: [{ role: "user", content: "hello" }] }) + }) + assert.equal(postRes.status, 403, "pre-only key should get 403 on post gateway") + + // Step 5: Rotate the key + const rotateRes = await fetch(`${BASE_URL}/api/admin/api-users/${user.id}/rotate`, { + method: "POST", + headers: { Cookie: sessionCookie } + }) + assert.equal(rotateRes.status, 200, "rotate should succeed") + const { plaintextKey: newKey } = await rotateRes.json() + + // Old key should now get 410 + const oldKeyRes = await fetch(`${PRE_API_URL}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${plaintextKey}` + }, + body: JSON.stringify({ model: "test", messages: [] }) + }) + assert.equal(oldKeyRes.status, 410, "old key should return 410 after rotation") + + // Step 6: Download JSONL and verify + const logsRes = await fetch( + `${BASE_URL}/api/admin/logs/download?api_user_id=${user.id}`, + { headers: { Cookie: sessionCookie } } + ) + assert.equal(logsRes.status, 200, "logs download should succeed") + const contentType = logsRes.headers.get("content-type") + assert.match(contentType, /ndjson/, "response should be ndjson") + + const body = await logsRes.text() + // There should be at least one JSONL line from the pre API call + const lines = body.trim().split("\n").filter(Boolean) + assert.ok(lines.length >= 1, "should have at least one log entry") +})