readme: document both hermes routes and the toolset grant

Fills in the subscription/wire name table for all five mappings rather
than the two prose examples, and records why the routes are written as
config instead of subscribed -- including that the toolset grant is
deliberate but not enforced, since the file it lives in is inside
HERMES_WRITE_SAFE_ROOT.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
This commit is contained in:
2026-08-24 03:10:43 +02:00
co-authored by Claude Opus 5
parent 753573aeea
commit 152c38b56b
+51 -21
View File
@@ -39,14 +39,21 @@ run. Each service module opens its own firewall ports.
## Gitea events to Hermes
Jupiter's Gitea registers a webhook straight at Hermes on mars,
`http://mars.orbit.sol:8644/webhooks/gitea-pr-comments`, with no relay in
between. Gitea's `addDefaultHeaders` signs every webhook type with
Jupiter's Gitea registers one webhook per Hermes route, straight at Hermes on
mars (`http://mars.orbit.sol:8644/webhooks/<route>`), with no relay in between:
| route | subscribed gitea events | wakes luna on |
| --- | --- | --- |
| `gitea-pr-comments` | `pull_request_comment` | a timeline comment on a PR |
| `gitea-pr-reviews` | `pull_request_review_comment`, `pull_request_review_rejected` | a review with a body, or changes requested |
Approvals are deliberately not subscribed: an approval is darman signing off,
not asking for work. Gitea's `addDefaultHeaders` signs every webhook type with
`X-Hub-Signature-256` in GitHub's exact format and sends `X-GitHub-Event`
unconditionally — which is exactly what Hermes validates against the
subscription secret and reads the event name from, so the two speak the same
protocol without translation. The URL path is the Hermes route name, so
another subscription is just another hook.
unconditionally — which is exactly what Hermes validates against the route
secret and reads the event name from, so the two speak the same protocol
without translation. The URL path is the Hermes route name, so another route
is just another hook.
Gitea will only deliver to hosts in `[security] ALLOWED_HOST_LIST`, which
defaults to `external` and does NOT include tailnet addresses
@@ -56,25 +63,48 @@ that, deliveries fail with `webhook can only call allowed HTTP servers`.
Gitea names webhook events twice, and the two namespaces collide. The hook's
`events` array takes the *subscription* name; `X-GitHub-Event`, which is what
Hermes matches `--events` against, carries a lossy *wire* name from
`HookEventType.Event()`. A comment on a PR subscribes as
`pull_request_comment` but arrives as `issue_comment`, while
`pull_request_comment` on the wire means a review submission. So
`services/dev/gitea.nix` and `hosts/mars/hermes-agent.nix` deliberately name
the same event differently; `X-GitHub-Event-Type` carries the subscription
name, but Hermes does not read it.
each Hermes route matches its `events` against, carries a lossy *wire* name
from `HookEventType.Event()`:
The route's prompt and its filter script live in `hosts/mars/`, bind-mounted
read-only from the nix store so the agent cannot edit its own loop guard out,
and are re-subscribed by `hermes-agent-webhook-route` on every start. Run
`python3 hosts/mars/gitea-pr-comment-filter-test.py` after editing the filter.
| subscription | wire | what it is |
| --- | --- | --- |
| `pull_request_comment` | `issue_comment` | comment on a PR |
| `pull_request_review_comment` | `pull_request_comment` | review with a body |
| `pull_request_review_rejected` | `pull_request_rejected` | changes requested |
| `pull_request_review_approved` | `pull_request_approved` | approval |
So `services/dev/gitea.nix` and `hosts/mars/hermes-agent.nix` deliberately name
the same event differently, and neither is a typo. `X-GitHub-Event-Type`
carries the subscription name, but Hermes does not read it.
Each route's prompt and filter script live in `hosts/mars/`. The filters are
bind-mounted read-only from the nix store so the agent cannot edit her own
loop guard out; run
`python3 hosts/mars/gitea-pr-comment-filter-test.py` and
`python3 hosts/mars/gitea-pr-review-filter-test.py` after editing either.
`hermes-agent-webhook-routes` writes the routes into
`~/.hermes/webhook_subscriptions.json` directly, host-side, rather than
calling `hermes webhook subscribe`. That CLI has no `--toolsets` flag, and
without a toolset override a webhook run gets Hermes's constrained default
(`web_search`, `web_extract`, `vision_analyze`, `clarify`) — no shell, no file
access, so neither prompt can actually be carried out. Upstream's documented
answer is to add the `toolsets` key to that file by hand, which does not
survive a re-provision, so the whole route definition lives in nix instead.
The grant (`terminal`, `file`, `web`) is therefore deliberate and restored on
every start — but note it is not *enforced*: that file sits inside
`HERMES_WRITE_SAFE_ROOT`, so luna can widen her own toolset until the unit
next runs. The real backstop is gitea's branch protection on `master`.
Routes the unit does not name are left untouched, so retiring one is a manual
`sudo podman exec hermes-agent hermes webhook remove <name>` on mars — and
likewise its hook in the repo's Settings → Webhooks.
Before deploying either host, add the same random
`gitea_hermes_webhook_secret` value to both `secrets/mars.yaml` and
`secrets/jupiter.yaml` using `scripts/edit_secrets`, with no trailing newline
the value reaches Hermes through an env-file template, where a newline both
corrupts the file and changes the key the HMAC is computed with. The value is
intentionally not included in the repository.
a newline would change the key the HMAC is computed with, and the two ends
would disagree. The value is intentionally not included in the repository.
## Test in VirtualBox (no hardware needed)