Commit Graph
8 Commits
Author SHA1 Message Date
darmanandClaude Opus 5 e75f474726 hermes: match --events issue_comment, not pull_request_comment
A timeline comment on a PR never reached the route. Gitea reuses the same
strings in two namespaces and they collide:

  subscription name            wire name (X-GitHub-Event)   what it is
  pull_request_comment         issue_comment                comment on a PR
  issue_comment                issue_comment                comment on an issue
  pull_request_review_comment  pull_request_comment         review on a PR

The hook's `events` array takes the subscription name; Hermes matches
--events against X-GitHub-Event, the wire name, produced by
HookEventType.Event() in modules/webhook/type.go. So --events
pull_request_comment was selecting review submissions and could never match a
comment -- the exact inversion of what it reads like.

That also explains both observed failures. The review submission matched
(wire name pull_request_comment) and reached the filter, which correctly
dropped it on action=reviewed since a PullRequestPayload carries no comment
object. The timeline comment arrived as issue_comment, matched nothing, and
was dropped by the events filter before the script ever ran.

gitea.nix and hermes-agent.nix now deliberately name the same event
differently, so both carry the table and say the other is not a typo.

issue_comment on the wire also covers comments on plain issues. The hook does
not subscribe those, and the filter's is_pull check drops them regardless, so
widening the hook later cannot leak issue comments into the agent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-23 08:46:31 +02:00
darmanandClaude Opus 5 2a1a1628e1 relay: remove it; gitea already speaks Hermes's protocol
The relay existed on the premise that Gitea sends no header Hermes can read
an event name from, so something had to copy X-Gitea-Event into
X-GitHub-Event. That premise was wrong. Gitea's addDefaultHeaders sets

  req.Header["X-GitHub-Delivery"]   = []string{t.UUID}
  req.Header["X-GitHub-Event"]      = []string{event}
  req.Header["X-GitHub-Event-Type"] = []string{eventType}

unconditionally, for every webhook type, alongside X-Hub-Signature-256 in
GitHub's exact format. (Direct map assignment rather than .Add() specifically
to keep the "GitHub" casing that canonicalisation would destroy.) Hermes
validates that signature on any route without provider gating and reads the
event name from that header, so gitea and hermes already speak the same
protocol and the translation layer was translating nothing.

Gitea now posts straight at http://mars.orbit.sol:8644/webhooks/gitea-pr-comments.
The URL path is the Hermes route name, so a second subscription is a second
hook and nothing else -- the route-in-path indirection the relay grew was a
reimplementation of something Hermes already had.

Removes the module, the 200-line relay, its test, the mars import, the 8645
listener, and the stale gitea-hermes-webhook-relay.service entry left in the
secret's restartUnits. hermes-agent-webhook-route moves to
hosts/mars/hermes-agent.nix, next to the container and the read-only prompt
and filter mounts it depends on.

Also makes that unit refuse to subscribe when GITEA_HERMES_WEBHOOK_SECRET is
unset in the container, matching the existing empty-prompt check. An empty
secret silently fails every delivery signature check afterwards while the
unit still reports success -- the worst possible failure shape, and one this
setup can actually produce on a first deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-23 08:09:01 +02:00
darmanandClaude Opus 5 31ba001d06 hermes: give the gitea-events route its prompt and event filter
Completes the subscription: it had a secret, a delivery target and a script,
but no prompt and no event list, so it woke the agent on every forwarded
event with nothing to tell her what to do.

--events pull_request_comment narrows the route to the one event the prompt
handles. This only works because the relay copies X-Gitea-Event into
X-GitHub-Event; without that every delivery arrives as "unknown" and matches
nothing. Gitea sends pull_request_comment distinctly from issue_comment, so
plain issue comments no longer reach the agent at all. The Gitea-side hook
still posts the full event set to the relay and Hermes drops the rest before
any LLM call.

The prompt lives in hosts/mars/gitea-pr-comment-prompt.md, mounted read-only
next to the filter, and is read with $(cat) at subscribe time rather than
passed inline. That is not only about escaping: the text has to survive nix
`` string escaping, the systemd unit file, and `podman exec sh -c '...'`
single-quoting. It contains an apostrophe ("the PR's head branch") that
would terminate that single-quoted string early. Read from a file at runtime
the content never passes through shell source, so it can contain anything.
Verified end to end against the rendered unit with stubbed podman/hermes:
the value reaching --prompt is byte-identical to the repo file apart from
the trailing newline that command substitution strips.

`set -eu` inside the container shell is load-bearing. Without it a missing
prompt file makes cat fail, the substitution yields "", and the subscription
is created with an empty prompt -- a silent failure that still looks like a
healthy unit.

On what read-only does not buy: it protects the sources, and this unit
re-subscribes from them on every start, so a restart restores the intended
prompt, filter and events. The live subscription itself lives in
webhook_subscriptions.json under /opt/data and is hot-reloaded, which is
inside the agent's own write-safe root -- a self-modification would stick
until this unit next runs.

The prompt keeps its own stop conditions even though the filter already drops
those deliveries, and says explicitly that reaching them means the filter
failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-23 06:41:50 +02:00
darmanandClaude Opus 5 1fc4395068 hermes: add read-only Gitea PR comment filter, break the reply loop
The gitea-events subscription woke the agent on every delivery. That is an
unbounded loop as soon as she is given a prompt that tells her to answer on
the PR: her answer is itself a pull_request_comment, which wakes her again.

Adds a Hermes route script that drops the deliveries that must never reach an
LLM call: luna's own comments (the loop guard), "deleted" actions (the body
is still in the payload, so acting on one means acting on a request that was
explicitly withdrawn), non-pull-request comments, empty bodies, and edits
that did not actually change the body — a label or attachment change fires
"edited" too. Everything else passes through unchanged.

Mounted READ-ONLY from the nix store rather than written into hermesHome.
Hermes resolves route scripts under ~/.hermes/scripts, which here is inside
/opt/data — HERMES_WRITE_SAFE_ROOT — so a filter written there would be a
loop guard sitting in the writable root of the agent it constrains. Deleting
it fails closed (Hermes treats a missing script as "ignore"), but rewriting
it to always-allow would silently restore the loop. Read-only from the store
makes that impossible and keeps the guard in git.

The script also normalises changes.body.from to always exist. Gitea omits
`changes` entirely on created events, and Hermes replaces the prompt payload
with whatever JSON the script emits, so guaranteeing the key here means a
prompt referencing {changes.body.from} renders empty instead of leaving an
unfilled placeholder.

Note the stdout contract (gateway/platforms/webhook.py): only exactly
"[SILENT]", empty output, or a nonzero exit drop a delivery. Any OTHER text
on stdout lets it through and is attached as script_output — so a stray
debug print would silently defeat the filter. All diagnostics go to stderr,
and gitea-pr-comment-filter-test.py asserts that discipline along with each
drop rule (25 cases). Run it after any edit: the fail-closed behaviour means
a syntax error produces silence, not an error.

--events is still unset; event selection remains runtime-tunable policy.
The filter covers only what must not be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-23 06:32:31 +02:00
darmanandClaude Opus 5 50f83971de hermes: stop provisioning luna a working copy, fix her git/tea access
Three fixes to how luna's git/tea credentials are set up on mars, all found
against the running instance on 2026-08-23.

Drop the host-side clone. hermes-agent-prepare-dirs used to clone this repo
into ${hermesHome}/workspace/homelab, but nothing ever told luna at runtime
that it was there — she self-manages config/profiles/memories, so a path
baked into this file never reached her. She searched /opt/data/homelab and
/workspace, found neither, and concluded she had no repo at all. The
credentials are what actually grant access; any checkout is hers to make
anywhere inside HERMES_WRITE_SAFE_ROOT. The stale directory left by the old
version is deliberately not cleaned up, just unmanaged from here on.

Point credential.helper at the CONTAINER's path. It was written as the host
path (${hermesHome}/.git-credentials), which does not exist inside the
container where git actually reads the config — broken this way from 3c1f3e5
until now. Nothing host-side consumes those credentials any more, so the
container's view is the only one that has to be right; added `containerHome`
to make the distinction explicit at the point of use.

Chown what the oneshot writes. The image's cont-init only chowns the top
level of hermesHome and its own state — it does not recurse into the
root-owned 0600 files this unit drops there (.git-credentials, and tea's
config.yml, which tea also writes 0600), even though it runs afterwards. The
symptom was not an error but an absence: git reported no credential helper
and tea no login. Uses `if` rather than `[ -d x ] && chown` because under
`set -e` a false test on the left of an && list aborts the unit.

gitea.nix carries the matching comment updates: the luna provisioning unit is
server-side only, and her token needs write:repository,write:issue,read:user.
write:issue is the one that is easy to miss — a pull request IS an issue in
gitea's data model, so /pulls endpoints gate on the issue scope category and
`tea pr create` fails with write:repository alone even though clone, fetch
and push all work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-23 06:30:28 +02:00
darmanandClaude Sonnet 5 3c1f3e5fc3 mars: give L.U.N.A. direct git+tea access to the homelab repo
Provisions a dedicated PR-tier gitea account (luna) with branch protection
restricting master push/merge/approve to darman only, then wires git and
tea directly into the hermes-agent container (mounted from the host's Nix
store, credential-store + tea login set up by a host-side prepare oneshot,
repo cloned inside Hermes's own writable sandbox root at
/opt/data/workspace/homelab). Replaces an earlier standalone MCP-server
approach, scrapped in favor of direct CLI access for simplicity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FHr5ug9pu8q4XPrRkFnzJ
2026-08-22 20:50:35 +02:00
darmanandClaude Sonnet 5 a1cd6ae6f1 mars: add hermes CLI shell alias
darman's own podman is rootless while the container runs under root's
(system) podman, so plain `podman exec` couldn't see it. Alias runs it
with sudo against the right socket.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du1WQRk1F8DenrPhf8TofF
2026-08-22 05:03:30 +02:00
darmanandClaude Sonnet 5 7d63ba95df add mars host, move Hermes Agent there from jupiter
New on-site host mars runs Hermes Agent as its sole service: joins the
tailnet, mounts jupiter's samba share at /mnt/jupiter (doubling as
Hermes's shared dropbox), and hosts state locally under /var/lib/hermes.
Same Authentik OIDC app/Telegram bot as before, just relocated — neptun's
hermes.mgaction.town vhost now points at mars.orbit.sol instead of jupiter.

hosts/jupiter/hermes-agent.nix and its three sops secrets are removed;
jupiter's Caddy vhost for it is gone too. Also refreshes tailscale_authkey
across all hosts and fixes two stale "erik@laptop" keys in flake.nix's
kexec/installer-iso images (leftover from a previous laptop, already
swapped out of common.nix back in 2fd5752) to darman@terra.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FHr5ug9pu8q4XPrRkFnzJ
2026-08-22 03:00:24 +02:00