diff --git a/briefings/hermes-agent.md b/briefings/hermes-agent.md index 7074f8b9..4aa43aa4 100644 --- a/briefings/hermes-agent.md +++ b/briefings/hermes-agent.md @@ -34,6 +34,10 @@ - **Upstream dashboard runs ~160 async handlers on one event loop with the SYNC httpx.Client (12 call sites, 0 AsyncClient)** — one blocking outbound call freezes every route (measured /login 0.00→7.78 s, freeze == timeout). Real risk on egress-restricted networks; fix is upstream. - **Chromium is NOT in the image** — doctor.py fetches it via `npx playwright install chromium` on first browser use (needs egress). The old "baked in" claim was false. - **`model.name` must be the bare hyphenated provider ID** (`claude-opus-4-6`); the chart prefixes `anthropic/` itself, unguarded — a pre-prefixed value renders `anthropic/anthropic/…`. The image's own default `anthropic/claude-opus-4.6` (dotted) is not in the provider's live model list. +- **Browser automation: NOT OPERABLE on v2026.8.31, proven by exhaustion (2026-09-02, six attempts).** The image ships THREE browser drivers and zero installed: default engine `lightpanda` (absent), `agent-browser` CLI (absent — and works perfectly once npm-installed: chromium downloads in 6 s to the image-pinned path, all system libs present, page fetches succeed by hand even under the gateway's scrubbed env), and the ACTUALLY-ACTIVE backend `browser_exec` → the `browser-use` CLI (absent; uv-installable) which even then demands a running Chrome to attach. The failure is silent: agent falls back to `web_extract` and the model paraphrases the tool error as "no Chrome available". Also: the run-as-hermes gateway has EMPTY HOME and no PLAYWRIGHT_BROWSERS_PATH; `/opt/data/.local/bin` IS on its PATH (volume — the right home for any future boot-warm). Do not re-litigate from the README's old "downloads on demand" claim; that described the download half only. **Attempt 7 SUCCEEDED — CDP attach is the supportable path**: a persistent headless chromium (`--headless --no-sandbox --remote-debugging-port=N --user-data-dir=/opt/data/.chrome-cdp`) plus `browser.cdp_url: http://127.0.0.1:N` gave a verified real navigation (CDP ws resolved in agent.log, example.com visible in chrome's own /json tab list, browser_exec 1.24s/147 chars). Productization = a chromium SIDECAR CONTAINER in the workload (loopback CDP; scratch /dev/shm already mounted; sidecar can run non-root, removing --no-sandbox) — proper 1.3.0 through the pipeline, not a boot-script hack. Headline Nous item regardless: the default backend is driverless out of the box. +- **1.2.1: `expose: dashboard` auto-sets `HERMES_DASHBOARD_PUBLIC_URL` from `$(CPLN_GLOBAL_ENDPOINT)`.** Without it, edge TLS termination made the dashboard register CLEARTEXT OAuth callbacks for MCP servers, which auth providers refuse ("cleartext http redirect_uris only for loopback", measured against auth.cpln.io) — this was a real user's "MCP not working". With it, UI-only OAuth works: Add server (auth: OAuth) → Authenticate → provider → straight back. Under `expose: api` the CLI paste-back flow is the path. Verified: 55 cpln tools over authenticated OAuth; tokens live on the volume and survive redeploys. +- **Anthropic keys must be WORKSPACE-SCOPED** — identity-linked keys 400 on every request (`anthropic-workspace-id is required`) and hermes has NO header hook for the anthropic provider (extra_headers is plumbed for ollama only; adapter hardcodes default_headers). Documented in README; Nous ask filed to widen extra_headers. +- **MCP auth state is unbadged in the UI** — Authenticate button and `auth: oauth` chip look identical before/after; the Test button / `hermes mcp test` is the truth (Nous list). - **The dashboard port-forward is `cpln port-forward {workload} 9119:9119 --gvc {gvc}`** — top-level command, positional ports; the `cpln workload port-forward -p` form the README used to show does not exist. - Model billing follows the seeded config — verify with the agent log's `model=` line, never the API response envelope diff --git a/hermes-agent/versions/1.2.1/Chart.yaml b/hermes-agent/versions/1.2.1/Chart.yaml new file mode 100644 index 00000000..06706219 --- /dev/null +++ b/hermes-agent/versions/1.2.1/Chart.yaml @@ -0,0 +1,17 @@ +apiVersion: v2 +name: hermes-agent +description: Nous Research Hermes Agent — self-hosted, model-agnostic AI agent with persistent memory and browser automation +type: application +version: 1.2.1 +appVersion: "2026.8.31" + +dependencies: + - name: cpln-common + version: 1.0.0 + repository: "oci://ghcr.io/controlplane-com/templates" + +annotations: + created: "2026-07-15" + lastModified: "2026-09-02" + category: "ai" + createsGvc: false diff --git a/hermes-agent/versions/1.2.1/README.md b/hermes-agent/versions/1.2.1/README.md new file mode 100644 index 00000000..f712a25a --- /dev/null +++ b/hermes-agent/versions/1.2.1/README.md @@ -0,0 +1,194 @@ +# Hermes Agent + +This app deploys [Hermes Agent](https://github.com/NousResearch/hermes-agent) by Nous Research — a self-hosted, model-agnostic AI agent that wraps any LLM with persistent memory, an OpenAI-compatible gateway API, a web dashboard, and browser automation (not functional in this image by default — see Browser automation below). You bring the model (an external API key); the agent brings the memory, tools, and interfaces around it. + +## Architecture + +- **Hermes Agent**: Stateful workload (single replica) running the supervised gateway. OpenAI-compatible API on 8642 (bearer-auth), web dashboard on 9119 (basic-auth); with public access enabled the single canonical HTTPS endpoint fronts one of the two — `publicAccess.expose` picks which (API by default). +- **Volumeset**: 10 GiB persistent storage at `/opt/data` — the SQLite memory database, sessions, learned skills, and agent config survive restarts and redeploys. +- **Identity + policy**: Least-privilege — the workload identity may `reveal` exactly the one prerequisite secret, nothing else. + +Single replica is by design: memory is a single-writer SQLite database and upstream forbids two gateways sharing one data directory. On restart, state persists on the volume and the agent resumes; only in-flight work and brief downtime are lost. + +## Prerequisites + +- **An LLM API key** from your provider — Anthropic, OpenAI, or any OpenAI-compatible endpoint (OpenRouter, Ollama, vLLM, …) via `provider: custom`. **Anthropic keys must be WORKSPACE-SCOPED** (created inside a workspace in the Anthropic console) — a default/identity-linked key fails every request with `HTTP 400: anthropic-workspace-id is required…`; recreate the key inside a workspace if you see that. +- **A dictionary secret** you create *before* installing (secrets are never passed through values). It holds three values; **name the keys however you like** and map them under `secret.keys` at install — an existing secret works unchanged. + + | Value | Required | Maps to | + |---|---|---| + | LLM API key for your provider | yes | `secret.keys.apiKey` | + | Bearer token clients present to the gateway API — **must be at least 16 characters** | yes | `secret.keys.apiServerKey` | + | Dashboard basic-auth password | when dashboard enabled | `secret.keys.dashboardPassword` | + + Hermes **rejects an API server key shorter than 16 characters** (this endpoint dispatches + terminal-capable agent work, so a guessable key is remote code execution) — generate one with + `openssl rand -hex 32`. If the key is too short the gateway still starts but the API never + serves, and the workload will not become ready. + + Create it in one command (the name `my-hermes-secret` matches the chart's default `secret.name`): + + ```bash + cpln secret create-dictionary --name my-hermes-secret \ + --entry "api-key=YOUR-LLM-API-KEY" \ + --entry "api-server-key=$(openssl rand -hex 32)" \ + --entry "dashboard-password=YOUR-STRONG-PASSWORD" + ``` + + Pass its name as `secret.name` at install (and override `secret.keys` if your key names differ). + +## Configuration + +### Image + +```yaml +image: nousresearch/hermes-agent:v2026.8.31 # pin the Hermes Agent image tag +``` + +### Model + +```yaml +model: + provider: anthropic # anthropic | openai | custom + name: "" # model override — use the provider's exact model ID (e.g. claude-opus-4-6, gpt-5; Anthropic IDs are hyphenated, never dotted). Bare name only — the chart adds the anthropic/ prefix itself. Empty = provider default. Recommended for non-anthropic providers. + baseUrl: "" # OpenAI-compatible endpoint; required when provider is "custom" + reasoningEffort: medium # none | low | medium | high — use "none" for non-reasoning models +``` + +**Any other OpenAI-compatible endpoint — OpenRouter, Ollama, vLLM, LM Studio, a proxy — uses `provider: custom`** with `baseUrl` set and that service's key as your `apiKey`. For example, OpenRouter: + +```yaml +model: + provider: custom + baseUrl: https://openrouter.ai/api/v1 + name: anthropic/claude-sonnet-4-5 +``` + +**Reasoning effort:** Hermes sends a reasoning effort with every request, and models that do not support reasoning reject it with a `400: Unsupported parameter: 'reasoning.effort'`. Set `reasoningEffort: none` for those (e.g. `gpt-4o`); leave the default for reasoning-capable models (e.g. `gpt-5`, `claude-opus-4-6`). + +### Secret + +```yaml +secret: + name: my-hermes-secret # name of the dictionary secret you created (see Prerequisites) + keys: # point each field at the key in YOUR secret that holds it + apiKey: api-key + apiServerKey: api-server-key + dashboardPassword: dashboard-password +``` + +### Dashboard + +```yaml +dashboard: + enabled: true # internal-only web UI on port 9119 + username: admin # basic-auth username; password is a key in the prerequisite secret +``` + +### Resources + +```yaml +# The min→max spread is the elasticity: idle floor at min, burst toward the +# burst ceiling for heavy agent turns and tool work. +resources: + minCpu: 500m + minMemory: 1Gi + maxCpu: 2000m + maxMemory: 4Gi +``` + +### Storage + +```yaml +volumeset: + capacity: 10 # initial GiB (minimum 10) — memory DB, sessions, skills, config + autoscaling: + enabled: false # set true to auto-expand the volume as state grows + maxCapacity: 100 # ceiling in GiB when autoscaling is enabled + minFreePercentage: 10 # scale up when free space drops below this + scalingFactor: 1.2 # multiplier applied on each scale-up +``` + +### Access + +```yaml +publicAccess: + enabled: false # expose the workload on the public canonical HTTPS endpoint + expose: api # api | dashboard — which surface the one canonical endpoint fronts (see below) + +internalAccess: + type: same-gvc # none | same-gvc | same-org | workload-list + workloads: [] # used with workload-list, e.g. //gvc/GVC/workload/NAME +``` + +**Choosing the public surface (`publicAccess.expose`):** a workload gets **one** canonical HTTPS endpoint, and it fronts a single port — you choose which surface that is. Only meaningful with `publicAccess.enabled: true`. + +| `expose` | Canonical endpoint serves | The other surface | +|---|---|---| +| `api` (default) | Gateway API (8642), bearer-auth | Dashboard stays internal — reach it via `cpln port-forward` | +| `dashboard` | Web dashboard (9119) behind its basic-auth login | API loses its public endpoint; still reachable internally at `RELEASE-hermes-agent.GVC.cpln.local:8642` | + +`expose: dashboard` puts a **basic-auth login form on the internet** — the dashboard password in your secret must be strong (`openssl rand -hex 32`), because whoever logs in operates a terminal-capable agent. + +## Connecting + +| Interface | Where | Auth | +|---|---|---| +| Gateway API (OpenAI-compatible) | From another workload by default (see below). With `publicAccess.enabled: true` and `expose: api` (the default), also on the canonical HTTPS endpoint — find it in `status.canonicalEndpoint` (`cpln workload get RELEASE-hermes-agent -o yaml`) | Bearer `API_SERVER_KEY` | +| Web dashboard | Internal by default — `cpln port-forward RELEASE-hermes-agent 9119:9119 --gvc GVC`, then `http://localhost:9119`. On the canonical HTTPS endpoint instead with `publicAccess.enabled: true` and `expose: dashboard` | Basic auth (`dashboard.username` + the dashboard password from your secret) | +| From another workload | `RELEASE-hermes-agent.GVC.cpln.local:8642` | Bearer `API_SERVER_KEY` | + +Example request against the gateway API: + +```bash +curl https://ENDPOINT/v1/chat/completions \ + -H "Authorization: Bearer $API_SERVER_KEY" \ + -H "Content-Type: application/json" \ + -d '{"messages":[{"role":"user","content":"hello"}]}' +``` + +## Messaging platforms (optional) + +Hermes supports chat-platform gateways (Telegram, Discord, Slack, and others). These are **configured after install**, using Hermes's own interactive setup — not through this template's values: + +```bash +cpln workload exec RELEASE-hermes-agent --gvc GVC --container hermes -- hermes gateway setup +``` + +Follow the prompts for your platform; the configuration is stored on the data volume. See the [Hermes documentation](https://github.com/NousResearch/hermes-agent) for each platform's requirements, such as bot tokens. + +## Browser automation + +**Not functional in this image (v2026.8.31) by default.** The agent's browser backend requires binaries the image does not ship (the `browser-use` CLI and a launchable Chrome stack), and installing them by hand still leaves the backend demanding a running Chrome to attach to. The failure is silent: browser requests are answered via `web_extract` with a note that the browser was unavailable — read replies carefully rather than assuming the tool ran. + +**The one verified path** is attaching a running Chrome over CDP: launch a headless Chromium reachable from the container and run `hermes config set browser.cdp_url http://:` (measured working end to end — the agent resolves the CDP websocket and drives real page navigation). A future template version may bundle that Chrome as a sidecar container; until then this is a manual, non-persistent setup. Text-level page fetching (`web_extract`) works normally without any of this. + +## Connecting MCP servers that need OAuth + +Many MCP servers (including Control Plane's own, `https://mcp.cpln.io/mcp`) authenticate with OAuth. Add the server on the dashboard's MCP page with **Authentication: OAuth**, then: + +- **With `publicAccess.expose: dashboard`** (recommended for dashboard use): click **Authenticate** — your browser goes to the provider, you sign in, and it redirects straight back to the dashboard. This works because the chart sets the dashboard's public URL automatically; tokens persist on the volume across restarts and redeploys. +- **With `expose: api`** the dashboard has no public URL for OAuth callbacks, so use the one-time CLI flow instead: `cpln workload connect {release}-hermes-agent --gvc {gvc} --container hermes`, then `hermes mcp login ` — open the printed URL in your browser, and when it lands on a `127.0.0.1:27890/callback` connection error (expected), paste that full URL back into the shell. + +If Authenticate fails with `registration failed … cleartext http redirect_uris are only allowed for loopback hosts`, the dashboard does not know its public https URL — on this chart that means you are on 1.2.0 or overrode the setting; set `dashboard.public_url` to the canonical endpoint via `hermes config set`. + +## Important Notes + +- **`publicAccess.enabled: true` publishes a terminal-capable agent to the internet**, guarded only by your bearer token. The agent's terminal backend runs unsandboxed as the container user with full file access, so anyone holding the key can execute work inside the workload. It is off by default — before enabling it, use a long random `api-server-key` (`openssl rand -hex 32`) and prefer restricting reach via `internalAccess`. +- **The API server key must be at least 16 characters** — Hermes rejects anything shorter, and the workload will not become ready. +- **An OAuth-connected MCP server acts AS THE PERSON WHO AUTHENTICATED IT.** Chat requests can then invoke those tools with that person's permissions — for Control Plane's MCP that includes creating and deleting real infrastructure. Connect write-capable MCP servers deliberately, and treat the dashboard password accordingly. +- **The dashboard is internal by default** — reach it via `cpln port-forward` (see Connecting). With the default `expose: api`, the public endpoint serves the **API only**, so browsing to it returns 404 at `/` by design — `GET /health` returning 200 is how to confirm the workload is up. Putting the dashboard on the internet instead is an explicit choice: `publicAccess.expose: dashboard`, which drops the API's public endpoint and demands a strong dashboard password. +- **A dashboard login that hangs (spinner, request pending forever) is almost always stale BROWSER state, not the server.** Port-forward tunnels that die mid-session leave wedged connections in the browser's profile; later logins then stall while every server surface is healthy. Fix: fully QUIT the browser and reopen (closing the tab is not enough) — a private window also works, which is the tell. Confirm the server side in seconds: `curl http://localhost:9119/login` through the tunnel; a fast 200 means the workload is fine. Pages that mount blank in a long-lived session are the same class — reload. +- **Single replica by design** — memory is single-writer SQLite; do not scale up. State persists on the volume across restarts. +- **The model is external** — cost and rate limits are governed by your LLM provider, not this workload. +- **Failed model calls return HTTP 200** with the error inside the body (`"finish_reason": "error"`, `"hermes": {"failed": true}`). A client that checks only the HTTP status will read a provider failure as success — inspect the body, or the agent log at `/opt/data/logs/agent.log`. +- **Keep `maxCpu` under 4× `minCpu`** — the platform rejects a wider ratio; raise `minCpu` if you raise `maxCpu`. +- **Rotating a value in your prerequisite secret does NOT reach a running workload** — `cpln://` references resolve at replica start and are never re-resolved, so the old credential keeps working silently. After any rotation, run `cpln workload force-redeployment RELEASE-hermes-agent --gvc GVC`. +- **Reset** requires `cpln helm uninstall` (deletes the volumeset) — changing the secret and redeploying does not wipe existing memory/config on the volume. + +## Links + +- [Hermes Agent (GitHub)](https://github.com/NousResearch/hermes-agent) +- [Documentation](https://github.com/NousResearch/hermes-agent/blob/main/README.md) +- [Nous Research](https://nousresearch.com/) +- [Control Plane docs](https://docs.controlplane.com/) diff --git a/hermes-agent/versions/1.2.1/templates/_helpers.tpl b/hermes-agent/versions/1.2.1/templates/_helpers.tpl new file mode 100644 index 00000000..9e6fd892 --- /dev/null +++ b/hermes-agent/versions/1.2.1/templates/_helpers.tpl @@ -0,0 +1,166 @@ +{{/* Resource Naming */}} + +{{/* +Hermes Agent Workload Name +*/}} +{{- define "hermes-agent.name" -}} +{{- printf "%s-hermes-agent" .Release.Name }} +{{- end }} + +{{/* +Hermes Agent Volumeset Name +*/}} +{{- define "hermes-agent.volume.name" -}} +{{- printf "%s-hermes-agent-vs" .Release.Name }} +{{- end }} + +{{/* +Hermes Agent Identity Name +*/}} +{{- define "hermes-agent.identity.name" -}} +{{- printf "%s-hermes-agent-identity" .Release.Name }} +{{- end }} + +{{/* +Hermes Agent Policy Name +*/}} +{{- define "hermes-agent.policy.name" -}} +{{- printf "%s-hermes-agent-policy" .Release.Name }} +{{- end }} + + +{{/* Provider resolution */}} + +{{/* +Maps model.provider to the API-key env var name the workload reads from the +prerequisite secret. "custom" reuses the OpenAI-compatible key env. +*/}} +{{- define "hermes-agent.apiKeyEnv" -}} +{{- $p := .Values.model.provider -}} +{{- if eq $p "anthropic" -}}ANTHROPIC_API_KEY +{{- else -}}OPENAI_API_KEY +{{- end -}} +{{- end }} + +{{/* +The provider slug the IMAGE understands, which is not always the friendly name we +expose. The image's registry (hermes_cli.models.CANONICAL_PROVIDERS) has no +"openai" — it is "openai-api", and an unknown slug kills every request at agent +construction with RuntimeError: Unknown provider. +*/}} +{{- define "hermes-agent.providerSlug" -}} +{{- if eq .Values.model.provider "openai" -}}openai-api +{{- else -}}{{ .Values.model.provider }} +{{- end -}} +{{- end }} + +{{/* +The endpoint to seed as model.base_url. The image's generated config defaults to +openrouter for EVERY provider, so a non-openrouter key is sent to openrouter and +rejected unless we override it. An explicit model.baseUrl always wins. +*/}} +{{- define "hermes-agent.baseUrl" -}} +{{- $p := .Values.model.provider -}} +{{- if .Values.model.baseUrl -}}{{ .Values.model.baseUrl }} +{{- else if eq $p "anthropic" -}}https://api.anthropic.com +{{- else if eq $p "openai" -}}https://api.openai.com/v1 +{{- end -}} +{{- end }} + +{{/* +The model string to seed as model.default. Prefix handling is provider-specific: +"anthropic/" is required, while openai-api receives the prefix verbatim and +rejects it ("model 'openai-api/gpt-4o' does not exist"), so it must be bare. The +custom provider strips a leading prefix, so bare is safe there too. +*/}} +{{- define "hermes-agent.modelDefault" -}} +{{- if eq .Values.model.provider "anthropic" -}} +{{- printf "anthropic/%s" .Values.model.name -}} +{{- else -}} +{{- .Values.model.name -}} +{{- end -}} +{{- end }} + + +{{/* Config seed */}} + +{{/* +Commands run before `hermes gateway run` to make values authoritative over the +config on the data volume. The model CANNOT be set by env: HERMES_MODEL is only +ever WRITTEN by the image for subprocesses, never read as an input, and there is +no HERMES_*MODEL* env-override key. The model lives in config.yaml as +`model.default` in provider/name form, so it must be seeded via the CLI. This runs +on every boot, which makes values the source of truth across restarts. + +Every key is set via a DOTTED path on purpose. The bare `hermes config set model ` +form writes a scalar `model:` that replaces the whole mapping and destroys the +sibling `default`/`provider`/`base_url` keys; a later `config set model.provider` +against that scalar then wipes the model value entirely. Dotted paths only ever +touch one leaf, so the mapping stays intact regardless of ordering. +*/}} +{{- define "hermes-agent.configSeed" -}} +hermes config set model.provider {{ include "hermes-agent.providerSlug" . | quote }} +hermes config set model.base_url {{ include "hermes-agent.baseUrl" . | quote }} +{{- if .Values.model.name }} +hermes config set model.default {{ include "hermes-agent.modelDefault" . | quote }} +{{- end }} +hermes config set agent.reasoning_effort {{ .Values.model.reasoningEffort | quote }} +{{- end }} + + +{{/* Validation */}} + +{{- define "hermes-agent.validate" -}} +{{- include "hermes-agent.validateResourceKnobs" . -}} +{{- if not (has .Values.model.provider (list "anthropic" "openai" "custom")) -}} +{{- fail (printf "hermes-agent: model.provider must be one of anthropic, openai, custom — got '%s'. Any other OpenAI-compatible endpoint (OpenRouter, Ollama, vLLM, …) uses provider 'custom' with model.baseUrl." .Values.model.provider) -}} +{{- end -}} +{{- if and (eq .Values.model.provider "custom") (not .Values.model.baseUrl) -}} +{{- fail "hermes-agent: model.baseUrl is required when model.provider is 'custom'" -}} +{{- end -}} +{{- if and (eq .Values.model.provider "anthropic") .Values.model.baseUrl -}} +{{- fail "hermes-agent: model.baseUrl has no effect with model.provider 'anthropic' — the Anthropic client ignores it (verified: requests still reach Anthropic even when base_url points at a blackhole). To route through a proxy or any OpenAI-compatible endpoint, use model.provider 'custom'." -}} +{{- end -}} +{{- if not (has .Values.model.reasoningEffort (list "none" "low" "medium" "high")) -}} +{{- fail (printf "hermes-agent: model.reasoningEffort must be one of none, low, medium, high — got '%s'" .Values.model.reasoningEffort) -}} +{{- end -}} +{{- if not .Values.secret.name -}} +{{- fail "hermes-agent: secret.name is required — create the prerequisite dictionary secret first (see README → Prerequisites)" -}} +{{- end -}} +{{- if and .Values.volumeset.autoscaling.enabled (gt (int .Values.volumeset.capacity) (int .Values.volumeset.autoscaling.maxCapacity)) -}} +{{- fail (printf "hermes-agent: volumeset.autoscaling.maxCapacity (%v) must be >= volumeset.capacity (%v)" .Values.volumeset.autoscaling.maxCapacity .Values.volumeset.capacity) -}} +{{- end -}} +{{- if not (has .Values.internalAccess.type (list "none" "same-gvc" "same-org" "workload-list")) -}} +{{- fail (printf "hermes-agent: internalAccess.type must be none, same-gvc, same-org, or workload-list — got '%s'" .Values.internalAccess.type) -}} +{{- end -}} +{{- if not (has .Values.publicAccess.expose (list "api" "dashboard")) -}} +{{- fail (printf "hermes-agent: publicAccess.expose must be api or dashboard — got '%s'. The workload has ONE canonical endpoint; this picks which surface it fronts." .Values.publicAccess.expose) -}} +{{- end -}} +{{- if and (eq .Values.publicAccess.expose "dashboard") (not .Values.dashboard.enabled) -}} +{{- fail "hermes-agent: publicAccess.expose 'dashboard' requires dashboard.enabled: true — there is no dashboard to front." -}} +{{- end -}} +{{- end }} + + +{{/* Labeling */}} + +{{/* +Common tags +*/}} +{{- define "hermes-agent.tags" -}} +{{- include "cpln-common.tags" . }} +{{- end }} + +{{/* +Reject the pre-rename bare cpu/memory keys. Left unguarded they are silently +ignored and the chart falls back to its own default limit — wrong resources +with no signal. +*/}} +{{- define "hermes-agent.validateResourceKnobs" -}} +{{- if (.Values.resources).cpu -}} +{{- fail "hermes-agent: resources.cpu was RENAMED to resources.maxCpu. A block exposing both a reservation and a limit names the limit maxCpu/maxMemory, so the bare name is no longer read and would be silently ignored. Rename it in your values." -}} +{{- end -}} +{{- if (.Values.resources).memory -}} +{{- fail "hermes-agent: resources.memory was RENAMED to resources.maxMemory. A block exposing both a reservation and a limit names the limit maxCpu/maxMemory, so the bare name is no longer read and would be silently ignored. Rename it in your values." -}} +{{- end -}} +{{- end -}} diff --git a/hermes-agent/versions/1.2.1/templates/identity.yaml b/hermes-agent/versions/1.2.1/templates/identity.yaml new file mode 100644 index 00000000..fd68e377 --- /dev/null +++ b/hermes-agent/versions/1.2.1/templates/identity.yaml @@ -0,0 +1,5 @@ +kind: identity +name: {{ include "hermes-agent.identity.name" . }} +description: Hermes Agent workload identity +gvc: {{ .Values.global.cpln.gvc }} +tags: {{- include "hermes-agent.tags" . | nindent 4 }} diff --git a/hermes-agent/versions/1.2.1/templates/policy.yaml b/hermes-agent/versions/1.2.1/templates/policy.yaml new file mode 100644 index 00000000..ebdf75f7 --- /dev/null +++ b/hermes-agent/versions/1.2.1/templates/policy.yaml @@ -0,0 +1,12 @@ +kind: policy +name: {{ include "hermes-agent.policy.name" . }} +description: Hermes Agent secret access +tags: {{- include "hermes-agent.tags" . | nindent 4 }} +bindings: + - permissions: + - reveal + principalLinks: + - //gvc/{{ .Values.global.cpln.gvc }}/identity/{{ include "hermes-agent.identity.name" . }} +targetKind: secret +targetLinks: + - //secret/{{ .Values.secret.name }} diff --git a/hermes-agent/versions/1.2.1/templates/volumeset.yaml b/hermes-agent/versions/1.2.1/templates/volumeset.yaml new file mode 100644 index 00000000..ae5c9e7f --- /dev/null +++ b/hermes-agent/versions/1.2.1/templates/volumeset.yaml @@ -0,0 +1,18 @@ +kind: volumeset +name: {{ include "hermes-agent.volume.name" . }} +gvc: {{ .Values.global.cpln.gvc }} +description: Hermes Agent durable state (memory DB, sessions, skills, config) +tags: {{- include "hermes-agent.tags" . | nindent 4 }} +spec: + fileSystemType: ext4 + initialCapacity: {{ .Values.volumeset.capacity }} + {{- if .Values.volumeset.autoscaling.enabled }} + autoscaling: + maxCapacity: {{ .Values.volumeset.autoscaling.maxCapacity }} + minFreePercentage: {{ .Values.volumeset.autoscaling.minFreePercentage }} + scalingFactor: {{ .Values.volumeset.autoscaling.scalingFactor }} + {{- end }} + performanceClass: general-purpose-ssd + snapshots: + createFinalSnapshot: true + retentionDuration: 7d diff --git a/hermes-agent/versions/1.2.1/templates/workload.yaml b/hermes-agent/versions/1.2.1/templates/workload.yaml new file mode 100644 index 00000000..cae56d14 --- /dev/null +++ b/hermes-agent/versions/1.2.1/templates/workload.yaml @@ -0,0 +1,162 @@ +{{- include "hermes-agent.validate" . }} +kind: workload +name: {{ include "hermes-agent.name" . }} +description: Hermes Agent — self-hosted AI agent gateway (API + dashboard) +gvc: {{ .Values.global.cpln.gvc }} +tags: {{- include "hermes-agent.tags" . | nindent 4 }} +spec: + type: stateful + identityLink: //identity/{{ include "hermes-agent.identity.name" . }} + containers: + - name: hermes + image: {{ .Values.image }} + # The image's main-hermes s6 service is a no-op (`exec sleep infinity`) and the + # image has no ENTRYPOINT — /init is its CMD, and the default CMD is the + # interactive TUI, which exits on non-TTY stdin. The gateway must therefore be + # the container's program, and must run THROUGH /init so s6-overlay keeps + # supervising the dashboard service. Dropping `command: /init` silently kills + # the dashboard while leaving the API working. + command: /init + args: + - /bin/sh + - -c + - | + set -e + # The container's boot env carries only PATH — the image's own + # HERMES_HOME does not reach it (exec sessions DO get it, which makes + # this easy to mis-verify). Without it, `hermes config set` silently + # writes to /opt/data/.hermes/config.yaml, which the gateway never reads. + export HERMES_HOME=/opt/data + {{- include "hermes-agent.configSeed" . | nindent 10 }} + exec hermes gateway run + inheritEnv: false + minCpu: {{ .Values.resources.minCpu | quote }} + minMemory: {{ .Values.resources.minMemory | quote }} + cpu: {{ .Values.resources.maxCpu | quote }} + memory: {{ .Values.resources.maxMemory | quote }} + env: + # ── LLM provider (key comes from the prerequisite secret) ── + - name: {{ include "hermes-agent.apiKeyEnv" . }} + value: 'cpln://secret/{{ .Values.secret.name }}.{{ .Values.secret.keys.apiKey }}' + - name: HERMES_INFERENCE_PROVIDER + value: {{ .Values.model.provider | quote }} + {{- if .Values.model.name }} + - name: HERMES_MODEL + value: {{ .Values.model.name | quote }} + - name: API_SERVER_MODEL_NAME + value: {{ .Values.model.name | quote }} + {{- end }} + {{- if eq .Values.model.provider "custom" }} + - name: OPENAI_BASE_URL + value: {{ .Values.model.baseUrl | quote }} + {{- end }} + # ── OpenAI-compatible gateway API (public via canonical endpoint) ── + - name: API_SERVER_ENABLED + value: '1' + - name: API_SERVER_HOST + value: 0.0.0.0 + - name: API_SERVER_PORT + value: '8642' + - name: API_SERVER_KEY + value: 'cpln://secret/{{ .Values.secret.name }}.{{ .Values.secret.keys.apiServerKey }}' + {{- if .Values.dashboard.enabled }} + # ── Web dashboard (internal-only, basic auth) ── + - name: HERMES_DASHBOARD + value: '1' + - name: HERMES_DASHBOARD_HOST + value: 0.0.0.0 + - name: HERMES_DASHBOARD_PORT + value: '9119' + - name: HERMES_DASHBOARD_BASIC_AUTH_USERNAME + value: {{ .Values.dashboard.username | quote }} + - name: HERMES_DASHBOARD_BASIC_AUTH_PASSWORD + value: 'cpln://secret/{{ .Values.secret.name }}.{{ .Values.secret.keys.dashboardPassword }}' + {{- if and .Values.publicAccess.enabled (eq .Values.publicAccess.expose "dashboard") }} + {{- /* The platform terminates TLS at the edge, so the dashboard sees + itself as http:// and registers a CLEARTEXT OAuth callback for + MCP servers -- which auth providers refuse ("cleartext http + redirect_uris are only allowed for loopback hosts", measured + live 2026-09-01 against auth.cpln.io). The operator knob is the + dashboard's public URL; CPLN_GLOBAL_ENDPOINT is the full https + canonical URL, injected per-workload and expanded from $(VAR) + at container start (the langfuse/plane pattern). Only set when + the dashboard IS the canonical endpoint. */}} + - name: HERMES_DASHBOARD_PUBLIC_URL + value: $(CPLN_GLOBAL_ENDPOINT) + {{- end }} + {{- end }} + ports: + # The FIRST declared port is the one the public canonical HTTPS endpoint + # fronts — publicAccess.expose picks which surface that is. Probes and + # internal DNS address ports by number, so ordering affects only the + # canonical endpoint. + {{- if eq .Values.publicAccess.expose "dashboard" }} + - number: 9119 + protocol: http + - number: 8642 + protocol: http + {{- else }} + - number: 8642 + protocol: http + {{- if .Values.dashboard.enabled }} + - number: 9119 + protocol: http + {{- end }} + {{- end }} + volumes: + - path: /opt/data + recoveryPolicy: retain + uri: cpln://volumeset/{{ include "hermes-agent.volume.name" . }} + # Shared memory for headless Chromium (default 64 MB /dev/shm crashes it). + - path: /dev/shm + uri: scratch://dev-shm + # /health is unauthenticated and only 200s once the API server is actually + # serving — unlike a TCP check, it catches a gateway that started but whose + # API refused to bind. failureThreshold max is 20 (platform-enforced). + readinessProbe: + httpGet: + path: /health + port: 8642 + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 20 + successThreshold: 1 + timeoutSeconds: 3 + livenessProbe: + httpGet: + path: /health + port: 8642 + initialDelaySeconds: 120 + periodSeconds: 30 + failureThreshold: 5 + timeoutSeconds: 5 + defaultOptions: + autoscaling: + maxConcurrency: 0 + maxScale: 1 + metric: disabled + minScale: 1 + scaleToZeroDelay: 300 + target: 100 + capacityAI: false + debug: false + suspend: false + timeoutSeconds: 30 + firewallConfig: + external: + outboundAllowCIDR: + - 0.0.0.0/0 + {{- if .Values.publicAccess.enabled }} + inboundAllowCIDR: + - 0.0.0.0/0 + {{- else }} + inboundAllowCIDR: [] + {{- end }} + internal: + inboundAllowType: {{ .Values.internalAccess.type }} + {{- if eq .Values.internalAccess.type "workload-list" }} + inboundAllowWorkload: + {{- range .Values.internalAccess.workloads }} + - {{ . }} + {{- end }} + {{- end }} diff --git a/hermes-agent/versions/1.2.1/values.yaml b/hermes-agent/versions/1.2.1/values.yaml new file mode 100644 index 00000000..54410b40 --- /dev/null +++ b/hermes-agent/versions/1.2.1/values.yaml @@ -0,0 +1,48 @@ +image: nousresearch/hermes-agent:v2026.8.31 + +# ─── LLM provider ───────────────────────────────────────────────────────────── +model: + provider: anthropic # anthropic | openai | custom (any other OpenAI-compatible endpoint, e.g. OpenRouter) + name: "" # model override — use the provider's exact model ID (e.g. claude-opus-4-6, gpt-5; Anthropic IDs are hyphenated, never dotted). Bare name only — the chart adds the anthropic/ prefix itself. Empty = provider default + baseUrl: "" # OpenAI-compatible endpoint; required when provider is "custom" + reasoningEffort: medium # none | low | medium | high — use "none" for non-reasoning models (e.g. gpt-4o) + +# ─── Prerequisite secret ────────────────────────────────────────────────────── +# A dictionary secret you create before installing (see README → Prerequisites). +# Point each field below at the key in YOUR secret that holds that value. +secret: + name: my-hermes-secret # name of your existing dictionary secret + keys: + apiKey: api-key # key holding the LLM API key for your provider + apiServerKey: api-server-key # key holding the bearer token clients present to the gateway API + dashboardPassword: dashboard-password # key holding the dashboard password (used when dashboard.enabled) + +# ─── Dashboard ──────────────────────────────────────────────────────────────── +dashboard: + enabled: true # internal-only web UI on port 9119 + username: admin # basic-auth username (password comes from the secret) + +# ─── Resources ──────────────────────────────────────────────────────────────── +resources: + minCpu: 500m # idle floor — maxCpu:minCpu ratio must stay under 4:1 + minMemory: 1Gi + maxCpu: 2000m # burst ceiling for heavy agent turns and tool work + maxMemory: 4Gi + +# ─── Storage ────────────────────────────────────────────────────────────────── +volumeset: + capacity: 10 # Initial capacity in GiB (minimum is 10) + autoscaling: + enabled: false # Set to true to enable autoscaling + maxCapacity: 100 # Maximum capacity in GiB when autoscaling is enabled + minFreePercentage: 10 # Minimum free percentage to trigger scaling when autoscaling is enabled + scalingFactor: 1.2 # Scaling factor to determine how much to scale up when autoscaling is triggered + +# ─── Access ─────────────────────────────────────────────────────────────────── +publicAccess: + enabled: false # expose the workload on the public canonical HTTPS endpoint — read the README security note first + expose: api # api | dashboard — which surface the ONE canonical endpoint fronts. "api" = gateway API (8642) public, dashboard stays internal. "dashboard" = web dashboard (9119) public behind its basic-auth login (use a strong password!), and the API is then internal-only + +internalAccess: + type: same-gvc # none | same-gvc | same-org | workload-list + workloads: [] # used with workload-list, e.g. //gvc/GVC/workload/NAME