Skip to content

Commit 85f582c

Browse files
stubbiclaude
andcommitted
docs: document the upstream s6 runtime, security/SCC tradeoff, provider config
- README: correct the now-false claims (read-only rootfs, uv-sync init container, tini PID 1). Describe the FROM-upstream s6 image (gateway + OpenAI API server + dashboard + Playwright browser + deps), the root-start→drop-to-1000 security posture and its OpenShift SCC implication, the /opt/data state path, /health on 8443, and the spec.config.raw + spec.env provider-config pattern. - docs/runtime.md (new): "Agent runtime" page covering the image, how the operator runs it, the security/SCC tradeoff, and provider configuration. Linked from docs/index.md and the mkdocs Operations nav. - docs/api-reference-generated.md: regenerated for the ShareProcessNamespace default/doc change (keeps the API Docs Sync check green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 01f1399 commit 85f582c

5 files changed

Lines changed: 124 additions & 9 deletions

File tree

README.md

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ helm install hermes-operator \
3535
oci://ghcr.io/paperclipinc/charts/hermes-operator \
3636
-n hermes-operator --create-namespace
3737

38-
# 2. Apply a minimal instance.
38+
# 2. Apply a minimal instance. The agent runs the upstream NousResearch/hermes-agent
39+
# s6 image (gateway + OpenAI-compatible API server), with /health on port 8443.
3940
kubectl apply -n agents -f - <<'YAML'
4041
apiVersion: hermes.agent/v1
4142
kind: HermesInstance
@@ -44,7 +45,18 @@ metadata:
4445
spec:
4546
image:
4647
repository: ghcr.io/paperclipinc/hermes-agent
47-
tag: "v2026.5.29.2"
48+
tag: "v0.16.0"
49+
# Point the gateway at an LLM provider and inject the key via spec.env.
50+
config:
51+
raw:
52+
model: gpt-4o-mini
53+
base_url: https://api.openai.com/v1
54+
env:
55+
- name: OPENAI_API_KEY
56+
valueFrom:
57+
secretKeyRef:
58+
name: hermes-llm
59+
key: apiKey
4860
storage:
4961
persistence:
5062
enabled: true
@@ -54,9 +66,16 @@ YAML
5466
# 3. Watch it converge.
5567
kubectl get hi -n agents -w
5668
# NAME READY PHASE IMAGE AGE
57-
# my-hermes True Ready ghcr.io/paperclipinc/hermes-agent:v2026.5.29.2 30s
69+
# my-hermes True Ready ghcr.io/paperclipinc/hermes-agent:v0.16.0 30s
5870
```
5971

72+
If you omit `spec.config.raw.model`, the operator injects a non-routable placeholder
73+
so the gateway and API server still come up (and `/health` passes) without making
74+
live LLM calls; inference then fails clearly until a real provider is set. Each
75+
instance also gets an operator-managed random `api_server_key` (in its
76+
`<name>-gateway-tokens` Secret) that authenticates the OpenAI-compatible
77+
`/v1/...` API; `/health` is unauthenticated. See [Agent runtime](docs/runtime.md).
78+
6079
For more involved scenarios, see [`examples/`](examples/).
6180

6281
## Architecture
@@ -87,7 +106,7 @@ flowchart LR
87106
STS[StatefulSet]
88107
Svc[Service]
89108
NetPol[NetworkPolicy default-deny]
90-
PVC[PVC ~/.hermes]
109+
PVC[PVC /opt/data]
91110
Honcho[Honcho Deploy<br/>profile store]
92111
CronJob[Backup CronJob]
93112
end
@@ -132,7 +151,7 @@ fields only: explicit values on the instance always win.
132151
| **Adaptive** | `HermesSelfConfig` for audited agent-initiated mutations | SSA under field manager `hermes.agent/selfconfig`. Policy-gated by `spec.selfConfigure.protectedKeys`. |
133152
| **Adaptive** | OCI-registry-driven auto-update | Channel-pinned polling, pre-update backup, probe-failure rollback. |
134153
| **Secure** | Default-deny NetworkPolicy + per-gateway allow rules | Derived from `spec.gateways` and `spec.networking.egress`. |
135-
| **Secure** | Read-only root filesystem | Writable `emptyDir`s for `/tmp` and `~/.config` subPaths. |
154+
| **Secure** | Hardened container security context | The upstream s6 runtime starts as root so `/init` (PID 1) can remap the in-image user to uid/gid 1000 and chown `/opt/data`, then every service drops to uid 1000 via `s6-setuidgid`. `allowPrivilegeEscalation=false`, `fsGroup=1000`, and seccomp `RuntimeDefault` remain; `runAsNonRoot`/read-only rootfs/drop-ALL-caps are not set (s6 needs `CHOWN`/`SETUID`/`SETGID`/`DAC_OVERRIDE`/`FOWNER` and a writable `/run`). Requires an SCC that permits a root-start container (e.g. `anyuid`); incompatible with OpenShift `restricted`/`restricted-v2`. See [Agent runtime](docs/runtime.md). |
136155
| **Secure** | Optional Tailscale Serve sidecar | Per-instance MagicDNS hostname + Tailscale TLS cert, no LoadBalancer/Ingress. See [Tailscale Serve](#tailscale-serve). |
137156
| **Secure** | Per-CRD validating + defaulting webhooks | Plus warnings on unknown config keys and unresolvable gateway tokens. |
138157
| **Secure** | RBAC aggregation labels | `kubectl auth can-i create hermesinstances --as=jane` works out of the box. |
@@ -141,13 +160,13 @@ fields only: explicit values on the instance always win.
141160
| **Observable** | [Grafana dashboard](docs/grafana/) | Ships as JSON. Variables: `namespace`, `instance`. |
142161
| **Observable** | Exhaustive [condition catalogue](docs/conditions.md) | Every condition × every reason code, documented and stable. |
143162
| **Multi-platform** | Telegram / Discord / Slack / WhatsApp / Signal gateways | First-class `spec.gateways.*` sections, secret-rotation-friendly. |
144-
| **Python runtime** | `uv`-installable agent runtime | Init container runs `uv sync` against a lockfile bundled in the agent image. |
145-
| **Python runtime** | FFmpeg + ripgrep available out of the box | Hard dependencies of hermes-agent. |
163+
| **Upstream runtime** | Ships the supported NousResearch/hermes-agent s6 image | The published `ghcr.io/paperclipinc/hermes-agent` is built `FROM` the upstream image (pinned by digest). It bundles the gateway, dashboard, OpenAI-compatible API server, a Playwright/Chromium browser, node, ffmpeg, and all Python deps. No init-container venv build — the old `uv sync` / `init-apt`/`init-uv`/`init-pip` chain is gone. See [Agent runtime](docs/runtime.md). |
164+
| **Upstream runtime** | FFmpeg, ripgrep, browser, node available out of the box | Bundled in the upstream hermes-agent image. |
146165
| **Scalable** | Optional HPA via `spec.availability.hpa` | StatefulSet retained for identity through restarts. |
147166
| **Scalable** | Optional `topologySpreadConstraints` | Sane defaults plus `spec.availability.topologySpreadConstraints` override. |
148167
| **Resilient** | PodDisruptionBudget auto-managed when `replicas > 1` | |
149168
| **Resilient** | Finalizer-driven backup-on-delete | `r.Patch` (JSON patch) for finalizer mutations, never `r.Update`. |
150-
| **Resilient** | Zombie-process reaper | `tini` as PID 1; `shareProcessNamespace: false` by default. |
169+
| **Resilient** | Zombie-process reaper | s6-overlay `/init` as PID 1 reaps zombies; `shareProcessNamespace: false` by default (its `/init` must be PID 1). |
151170
| **Backup / Restore** | S3-compatible backups | Scheduled, on-delete, pre-update. `tar.zst` snapshots + `meta.json`. |
152171
| **Backup / Restore** | Declarative one-shot restore | `spec.restoreFrom` is immutable once applied. |
153172
| **Migration** | One-shot OpenClaw → Hermes migration | From sibling `OpenClawInstance` or S3 backup. Uses hermes-agent's importer. |

docs-site/mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ nav:
8989
- Generated API Reference: api-reference-generated.md
9090
- Conditions: conditions.md
9191
- Operations:
92+
- Agent Runtime: runtime.md
9293
- Backup and Restore: backup-restore.md
9394
- Backup Format: backup-format.md
9495
- Auto-Update: autoupdate.md

docs/api-reference-generated.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ _Appears in:_
407407
| `availability` _[AvailabilitySpec](#availabilityspec)_ | Availability sets PDB, HPA, and topology-spread constraints. | | Optional: \{\} <br /> |
408408
| `probes` _[ProbesSpec](#probesspec)_ | Probes lets users override the built-in liveness/readiness/startup probes. | | Optional: \{\} <br /> |
409409
| `scheduling` _[SchedulingSpec](#schedulingspec)_ | Scheduling targets the agent pod at specific nodes. | | Optional: \{\} <br /> |
410-
| `shareProcessNamespace` _boolean_ | ShareProcessNamespace enables PID namespace sharing between all containers<br />in the pod. When true (the default), the infrastructure (pause) container<br />becomes PID 1 and reaps zombie processes, preventing accumulation of defunct<br />helper processes (git, plugins, shells) spawned under the agent entrypoint<br />when it does not call waitpid().<br />Security note: enabling this lets every container in the pod see and signal<br />every other container's processes. A compromised sidecar could send signals<br />to the agent and vice versa. Set to false to keep per-container PID isolation;<br />you are then responsible for reaping zombies (e.g. by baking tini or dumb-init<br />into the image). | true | Optional: \{\} <br /> |
410+
| `shareProcessNamespace` _boolean_ | ShareProcessNamespace enables PID namespace sharing between all containers<br />in the pod. Defaults to false: the upstream hermes-agent image runs under<br />s6-overlay, whose /init must be PID 1 (s6-overlay-suexec aborts otherwise),<br />and s6 already reaps zombies non-blocking on SIGCHLD — so sharing the process<br />namespace (which makes the pause container PID 1) is both incompatible and<br />unnecessary.<br />Security note: enabling this lets every container in the pod see and signal<br />every other container's processes. A compromised sidecar could send signals<br />to the agent and vice versa. Leave false to keep per-container PID isolation. | false | Optional: \{\} <br /> |
411411
| `initContainers` _[Container](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#container-v1-core) array_ | InitContainers is a user-supplied list of init containers appended after<br />any operator-managed init containers (e.g. runtime-init from Plan 3). | | Optional: \{\} <br /> |
412412
| `sidecars` _[Container](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#container-v1-core) array_ | Sidecars is a user-supplied list of sidecars appended after operator-managed<br />sidecars (e.g. ollama / web-terminal / tailscale from Plan 3). | | Optional: \{\} <br /> |
413413
| `extraVolumes` _[Volume](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#volume-v1-core) array_ | ExtraVolumes is a user-supplied list of additional pod volumes. | | Optional: \{\} <br /> |

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ This site is the rendered documentation for the operator. The source of truth fo
1212

1313
## Operations
1414

15+
- [Agent runtime](runtime.md) explains the upstream s6 image, how the operator runs it, the `/opt/data` state path, the security/SCC tradeoff, and LLM provider configuration.
1516
- [Conditions](conditions.md) documents the status conditions the operator sets.
1617
- [Backup and restore](backup-restore.md) and the [backup format](backup-format.md) cover data protection.
1718
- [Auto-update](autoupdate.md) describes registry polling and rollback.

docs/runtime.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Agent runtime
2+
3+
The operator runs the agent on the **upstream NousResearch/hermes-agent
4+
container image**. The published `ghcr.io/paperclipinc/hermes-agent` image is
5+
built `FROM` that upstream image (pinned by digest) with only operator metadata
6+
layered on top — there is no hand-rolled venv build any more.
7+
8+
## What the image bundles
9+
10+
The upstream image is an **s6-overlay** runtime. A single image ships:
11+
12+
- the **gateway** (the long-lived agent daemon),
13+
- a **dashboard**,
14+
- the **OpenAI-compatible API server**,
15+
- a **Playwright/Chromium browser**,
16+
- node, ffmpeg, ripgrep, and every Python dependency.
17+
18+
Because the runtime lives entirely in the image, the old init-container chain
19+
(`init-apt` / `init-uv` / `init-pip` running `uv sync` against a lockfile onto
20+
the PVC) is **gone**. Only mutable state lives on the volume.
21+
22+
## How the operator runs it
23+
24+
- **Args:** the container runs `["gateway", "run"]` — the foreground gateway
25+
daemon.
26+
- **API server:** enabled via env: `API_SERVER_ENABLED=true`,
27+
`API_SERVER_HOST=0.0.0.0`, `API_SERVER_PORT=8443`, and `API_SERVER_KEY` (see
28+
below). This serves the OpenAI-compatible `/v1/...` endpoint plus `/health` on
29+
the gateway port.
30+
- **Probes:** both **readiness and liveness** are `HTTPGet /health` on the
31+
gateway port (8443) — not a TCP socket.
32+
33+
## State and `HERMES_HOME`
34+
35+
Persistent state lives at **`/opt/data`**, the PVC mount, and `HERMES_HOME` is
36+
set to `/opt/data`. (The previous runtime used `/home/hermes/.hermes`.) The
37+
rendered `config.yaml` is mounted read-only at `/opt/data/config.yaml`.
38+
39+
## Security posture and the SCC tradeoff
40+
41+
The s6 runtime changes the pod's security posture. `/init` **must be PID 1 and
42+
start as root** so the s6 stage2 hook can remap the in-image `hermes` user to
43+
`HERMES_UID`/`HERMES_GID` (1000) and chown `/opt/data`. After that, every
44+
supervised service drops privileges via `s6-setuidgid`, so the actual workload
45+
runs as uid 1000.
46+
47+
Consequences for the pod:
48+
49+
- **No** `runAsNonRoot`/`runAsUser` (the container starts as root by design).
50+
- **No** read-only root filesystem (s6 needs a writable `/run` and `/etc` for
51+
the supervision tree).
52+
- **No** drop-ALL capabilities — s6 needs `CHOWN`, `SETUID`, `SETGID`,
53+
`DAC_OVERRIDE`, and `FOWNER` to remap the user and chown the volume.
54+
- `allowPrivilegeEscalation=false`, `fsGroup=1000`, and seccomp
55+
`RuntimeDefault` are retained.
56+
- `shareProcessNamespace` defaults to **false**: s6 reaps zombies itself, and
57+
its `/init` must be PID 1 (the pause container becoming PID 1 would break it).
58+
59+
This is a deliberate tradeoff to adopt the supported upstream runtime. It means
60+
the workload is **incompatible with OpenShift's `restricted`/`restricted-v2`
61+
SCC**, and requires an SCC that permits a root-start container (for example
62+
`anyuid`) — even though the workload internally drops to uid 1000.
63+
64+
## Configuring an LLM provider
65+
66+
Set the model and endpoint via `spec.config.raw`, and inject the API key via
67+
`spec.env`:
68+
69+
```yaml
70+
spec:
71+
config:
72+
raw:
73+
model: gpt-4o-mini
74+
base_url: https://api.openai.com/v1
75+
env:
76+
- name: OPENAI_API_KEY
77+
valueFrom:
78+
secretKeyRef:
79+
name: hermes-llm
80+
key: apiKey
81+
```
82+
83+
When no `model` is configured, the operator injects a non-routable placeholder
84+
provider so the gateway and API server still come up and `/health` passes,
85+
without making any live LLM calls. Inference then fails clearly until a real
86+
provider is configured.
87+
88+
## API server authentication
89+
90+
Each instance gets an operator-managed random `api_server_key` in its
91+
`<name>-gateway-tokens` Secret. It is wired into the container as
92+
`API_SERVER_KEY` and authenticates the OpenAI-compatible `/v1/...` API. The
93+
`/health` endpoint is **unauthenticated**, which is what the readiness and
94+
liveness probes hit.

0 commit comments

Comments
 (0)