Commit Graph
167 Commits
Author SHA1 Message Date
darmanandClaude Opus 5 54896033c6 feat(flake): add a quickshell hot-reload devShell
hosts/terra/home.nix ships dotfiles/quickshell via xdg.configFile, which
copies the tree into the store: ~/.config/quickshell is a read-only symlink
into /nix/store and every QML tweak costs a nixos-rebuild. quickshell does
hot-reload on file save -- but only for the files it watches, which are those
frozen store copies. `nix develop` now swaps the running shell to the working
tree (`qs -p`) and swaps it back on exit, so QML edits need no rebuild at all.

The swap starts the dev instance FIRST and kills the packaged one only once
dev is confirmed up. A QML error in the working tree then leaves you on your
normal bar instead of no bar, which matters because a broken save is exactly
when you would be running this. Liveness is "did `qs list -j` return json" --
it exits 0 whether or not it found anything, so the exit code says nothing.

Every kill is scoped to one config (`qs kill` = default, `qs kill -p` = that
path). A blanket kill would also take out unrelated instances; pkgs/rishot.nix
is one.

Three guards on the auto-swap, all learned by testing it:
  - interactive only. `nix develop --command X` EXECs X, replacing the shell
    that set the `trap ... EXIT`, so the restore never runs and you are left
    on the dev instance. Non-interactive use gets `nix develop -c qs-dev`.
  - WAYLAND_DISPLAY, so entering the shell over ssh cannot kill the desktop's
    bar and leave nothing in its place.
  - a sentinel, so a nested `nix develop` does not swap and restore twice.

Deliberately not wired to direnv (no .envrc): programs.direnv is enabled for
this user, so a `use flake` would swap the running desktop shell on every `cd`
into the checkout.

Verified end to end on terra: swap, hot-reload of a working-tree edit, and
restore, plus both the interactive and non-interactive paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgcWkm3t6BDQktYHQvb8Hx
2026-08-28 07:55:03 +02:00
luna 22fe8ab778 feat(quickshell): add dense telemetry bar 2026-08-27 23:52:47 +00:00
darmanandClaude Opus 5 94061bd80a monitoring: create the victoriametrics state dir, and pin the scrape timeout
The bind onto /var/lib/private/victoriametrics needs its source to exist or
the mount fails -- and because it is `nofail`, quietly: RequiresMountsFor is
satisfied by /mnt/data itself, so the service would start anyway and write
the TSDB to the eMMC, which is the one thing the bind exists to prevent.
prowlarr.nix has no tmpfiles rule only because its directory predates the
module (migrated from ZimaOS); this is a fresh service, so it creates its
own, same as seerr.nix. Verified on jupiter: the mount is live on md127 and
nothing lands on the OS disk.

scrape_timeout was left implicit at the Prometheus default of 10s, which is
longer than the 5s interval -- VictoriaMetrics clamps it down rather than
erroring, so the config claimed 10s while the scraper used 5s. Say what
actually happens. Checked with `victoria-metrics -promscrape.config.dryRun`,
not just nix eval, which never builds the checked-config derivation.

Also comments: why the bind exists and why `nofail` is load-bearing (the
fileSystems block had none, unlike prowlarr.nix and seerr.nix), and why
mercury needs its own job -- scrape_interval is per-job and job_name must be
unique, so its `job` label will always differ from the other hosts'. Select
on `host` in dashboards or mercury drops out of them silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-24 04:58:12 +02:00
darman 15fc18ab50 Merge pull request 'jupiter: add VictoriaMetrics monitoring' (#1) from feat/mars-victoriametrics into master
Reviewed-on: #1
Reviewed-by: darman <mail@erik-s.dev>
2026-08-24 04:48:45 +02:00
luna eec17b77df monitoring: move metrics state to the data array 2026-08-24 02:03:51 +00:00
luna b6aec1e307 monitoring: scrape mars node exporter 2026-08-24 01:45:00 +00:00
luna dfe8504402 monitoring: move VictoriaMetrics to Jupiter 2026-08-24 01:42:07 +00:00
luna 5764e6c644 mars: tune VictoriaMetrics scrape targets 2026-08-24 01:30:37 +00:00
darmanandClaude Opus 5 b99337adb7 gitea: subscribe the review hook to pull_request_review
The hook registered with no events at all and delivered nothing.
"pull_request_review_comment" and "pull_request_review_rejected" are real
HookEventTypes and real X-GitHub-Event-Type values, but they are not
things gitea's hook API accepts. updateHookEvents
(routers/api/v1/utils/hook.go) matches a fixed list of api names and
silently ignores anything else, so every event flag stayed false, the POST
succeeded, and the hook sat there inert.

There is no narrower api name: HasEvent (models/webhook/webhook.go)
collapses approved, rejected and review-comment onto
HookEventPullRequestReview, so `pull_request_review` is a single switch for
all three. Approvals consequently cannot be excluded at the hook any more.
They now cross the wire as "pull_request_approved", which is not in the
route's event list, so Hermes ignores them on the event match -- before the
filter script and before any LLM call. Gitea's delivery log will show them
answered 200/ignored, which is intended.

That makes three namespaces for the same event rather than two, so the
tables in both nix files and the README now carry the api column, and the
README warns about the silent-ignore behaviour that hid this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-24 03:27:52 +02:00
luna fb1226f9b5 Merge master into feat/mars-victoriametrics 2026-08-24 01:17:42 +00:00
luna dc037312b3 Merge master into feat/mars-victoriametrics 2026-08-24 01:15:07 +00:00
luna 082cbaff2a mars: reduce VictoriaMetrics retention to 15 days 2026-08-24 01:12:50 +00:00
darmanandClaude Opus 5 152c38b56b 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
2026-08-24 03:10:43 +02:00
darmanandClaude Opus 5 753573aeea gitea: register a hook per hermes route, and keep both secrets out of argv
One webhook per route, from a list, so adding a route is an entry rather
than a copy of the unit. The PR-review hook subscribes
pull_request_review_comment and pull_request_review_rejected.

The unit runs as the gitea user on a multi-user box, where
/proc/<pid>/cmdline is world-readable for the lifetime of the process, so
`-H "Authorization: token $t"` published the admin token and
`jq --arg secret "$s"` the webhook secret -- which is exactly what the
existing comment claimed to be avoiding by putting the body on stdin. The
token now goes through a 0600 `curl -K` config written with printf (a
shell builtin, so the substitution never reaches an argv) and the secret
through jq --rawfile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-24 03:10:43 +02:00
darmanandClaude Opus 5 c18413d16d hermes: write the webhook routes as config, and add a PR-review route
`hermes webhook subscribe` has no --toolsets flag, so a webhook run got
Hermes's constrained default (web_search, web_extract, vision_analyze,
clarify) -- no shell, no file access, which meant neither prompt could
actually be carried out: luna was woken, read the comment, and had no way
to act on it. Upstream's documented answer is to add the `toolsets` key to
webhook_subscriptions.json by hand, and a hand edit does not survive this
unit's re-provision. So the whole route definition moves here and the CLI
is not used at all.

The file is written host-side with jq. hermesHome is the bind-mount source
for /opt/data, so the container sees the same inode and hot-reloads it on
the next delivery -- no podman exec, no readiness loop, and no quoting
chain between nix and the prompt text. The merge is per-route: routes this
unit does not name survive, created_at is carried over, and every other
key is replaced outright so a hand-added `deliver_only` or `filters`
cannot linger.

The secret now comes from the sops file directly instead of being read
back out of the container's environment, which drops podman-hermes-agent
from restartUnits (the ordering constraint it existed for is gone) and
takes GITEA_HERMES_WEBHOOK_SECRET out of an env var luna can read.

The new gitea-pr-reviews route covers reviews with a body and
changes-requested. Those are not IssueCommentPayloads: gitea sends a
PullRequestPayload with action "reviewed" and a `review` object of exactly
{type, content} -- no review id, no line comments. So the prompt fetches
them with `tea pulls review-comments` and acts only on ones whose
`resolver` is empty, resolving each as it goes; with no stable id in the
payload, resolved state is the only workable duplicate-delivery guard.
An empty review body is deliberately NOT a drop, unlike in the comment
filter: a review whose substance is entirely in line comments has none.

Approvals are left unsubscribed -- an approval is darman signing off, not
asking for work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-24 03:10:33 +02:00
darmanandClaude Opus 5 6f99a1fed1 prompt: drop the nix eval validation step
Not executable under the toolset a webhook run actually got: Hermes
defaults those to web_search/web_extract/vision_analyze/clarify, with no
shell. Worth revisiting now that the routes grant `terminal` explicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-24 03:10:19 +02:00
darmanandClaude Opus 5 b516a800bf filter: make drops visible in the gateway log
Every drop so far has been silent. The script printed its reason to stderr
and exited 0 with "[SILENT]", but Hermes only logs stderr on the nonzero
path, as

  script ignored webhook path=... code=... stderr=...

so from outside, a deliberate drop, a crash, a timeout and a missing file all
looked identical: {"status":"ignored","reason":"script"} and nothing else.
Finding out which one it was meant re-running the payload through the script
by hand.

Drops now exit 3 with an empty stdout. Both still mean "ignored" to Hermes,
but the reason lands in the log. Exit 3 rather than 1 keeps a deliberate drop
distinguishable from an unhandled exception, which exits 1, so the code alone
says which happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-23 08:55:17 +02:00
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 6116ec4e5a gitea: name the hermes hook and send only PR comments
Names the webhook "PR comments Hermes" (gitea's CreateHookOption/EditHookOption
both carry an optional `name`, so it survives the create and the update path)
and narrows it from all 26 event types to pull_request_comment alone.

Gitea sends pull_request_comment distinctly from issue_comment, so the hook
now covers comments on pull requests and nothing else. Hermes would have
dropped the rest anyway -- its route filters on X-GitHub-Event before any LLM
call -- so this is defence in depth rather than the only gate, but it keeps
traffic that can never be acted on from crossing the wire and reaching the
agent's process at all.

The tradeoff is that event selection now lives on both sides: a second Hermes
route needs its event adding here as well as being subscribed. That is the
right way round for a single-purpose hook, and the comment says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-23 08:18:11 +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 503623551a secrets: rotate gitea_luna_token with the issue scope
The previous token was write:repository only, which clones, fetches and
pushes branches perfectly well and then fails at `tea pr create` — a pull
request is an issue in gitea's data model, so every /pulls endpoint gates on
the issue scope category rather than the repository one.

Regenerated with write:repository,write:issue,read:user. Confirmed against
the running instance: gitea reports the granted set as
  read:activitypub, read:misc, read:notification, read:organization,
  read:package, write:issue, write:repository, read:user
so write:issue is present rather than only read:issue, which would satisfy
the GET half and still fail the POST that opens the PR. The extra read:*
categories are gitea expanding the request, not something asked for.

No manual step on mars: gitea_luna_token already restarts
hermes-agent-prepare-dirs, which does delete-then-add for the tea login on
every start and so picks up the rotation by itself.

The old token is NOT revoked — gitea's CLI cannot delete tokens and the API
route needs basic auth as luna, which nothing here sets. It stays valid until
removed by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-23 07:58:20 +02:00
darmanandClaude Opus 5 941a6731bb gitea: add a gitea admin CLI alias on jupiter
Mirrors the `hermes` alias on mars. The admin CLI is effectively
undiscoverable without it: the package is not in systemPackages so `gitea` is
not on PATH at all, every admin subcommand needs GITEA_WORK_DIR pointed at a
stateDir that is not the module default, and it has to run as the gitea user
or it drops root-owned files into that directory. Getting any of the three
wrong fails in a different and unhelpful way.

Both the package path and the stateDir come from the config rather than being
written out, so a gitea bump or a stateDir move cannot leave the alias
pointing at something stale — which is exactly what a hardcoded /nix/store
path would do.

Lives in services/dev/gitea.nix, which only jupiter imports, so it does not
leak onto hosts with no gitea to administer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-23 07:49:05 +02:00
darmanandClaude Opus 5 ee3051f6e4 gitea: allow tailnet webhook targets
Webhook delivery to the hermes relay was refused outright:

  Post "http://mars.orbit.sol:8645/gitea/gitea-pr-comments":
  dial tcp 100.64.0.6:8645: webhook can only call allowed HTTP servers
  (check your security.ALLOWED_HOST_LIST setting),
  deny 'mars.orbit.sol(100.64.0.6:8645)'

ALLOWED_HOST_LIST defaults to `external`, documented as "a valid non-private
unicast IP". Tailscale addresses come from 100.64.0.0/10 — RFC 6598
carrier-grade NAT space — which is not RFC1918 private but does not satisfy
gitea's notion of external either, so every tailnet target is denied by
default. Nothing about the relay or the URL was wrong; the request never left
jupiter.

Sets the tailnet CIDR explicitly and keeps `external`, so a future webhook to
a public service still works without another edit here.

Goes in [security], not [webhook]: the webhook-section key is deprecated in
favour of this one and now merely falls back to it, and [security] is the
name the delivery error itself reports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-23 07:35:03 +02:00
darmanandClaude Opus 5 e14571d029 common: add jq to systemPackages
jq was only ever on the `path` of the units that call it, so it was absent
from an interactive shell — which made the hook-migration commands in the
README unrunnable on the host they target. It is a general-purpose tool and
every host already carries curl, so it belongs alongside it rather than being
pulled in per-unit.

Also simplifies those README commands now that jq is present, and uses mars's
existing `hermes` alias instead of spelling out the podman exec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-23 07:04:30 +02:00
darmanandClaude Opus 5 3567591ecf provisioning: stop deleting the pre-rename hook and subscription
Retiring gitea-events is a one-off migration, not something worth re-running
on every boot. Both units now only touch what they own: jupiter's creates or
updates its own hook and deletes nothing, and mars's removes only the route
it is about to re-subscribe, as the idempotency step for `subscribe`.

Keeping the deletes would have meant a redeploy could silently remove a hook
or route someone added deliberately -- a real risk now that sibling hooks
for other Hermes routes are the intended pattern.

README carries the manual commands, and the note that both hooks fire until
the old one is removed by hand, so events arrive twice in the meantime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-23 06:57:58 +02:00
darmanandClaude Opus 5 f982c6dc14 relay: take the Hermes route from the request path
Renames the subscription to gitea-pr-comments (it handles one event; the old
gitea-events name promised more than it delivered) and drops --deliver.

Rather than move the hardcoded route from one constant to another, the relay
now reads it from the request path: POST /gitea/<route> forwards to
<base>/webhooks/<route>. The route name was the last thing tying this service
to a specific subscription, so a second Hermes route is now a `hermes webhook
subscribe <name>` plus a Gitea hook at /gitea/<name>, with no relay change --
previously it would also have needed a second relay URL baked in here.

The path segment is interpolated into an outbound URL, so it is validated
against ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ and refused rather than sanitised
when it does not match. The path is matched raw and never URL-decoded, so
percent-encoded separators fail the charset check instead of surviving it;
requiring an alphanumeric first character also rejects "." and "..". Without
this, POST /gitea/..%2fadmin would let anything that can reach the relay
steer it at other Hermes endpoints. Tests cover traversal, encoded traversal,
embedded slashes, leading dot/dash, and the length bound, and assert nothing
reaches the stub Hermes in any of those cases.

Dropping --deliver leaves it at its default of `log`. The prompt tells her to
answer in the pull request, so the PR comment is the delivery and a Telegram
copy would only duplicate it; this also removes the hardcoded chat id that
was a third copy of TELEGRAM_HOME_CHANNEL.

Provisioning retires the pre-rename hook by its EXACT old URL rather than by
"points at the relay". Now that sibling hooks for other routes are the
intended pattern, a prefix match would delete them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-23 06:52:52 +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 Opus 5 2d9be98df7 desktop: add yaak
Desktop API client (REST/GraphQL/gRPC).

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 Opus 5 d0aec5b061 secrets: add gitea_hermes_webhook_secret to mars + jupiter
Shared HMAC secret for the Gitea -> Hermes webhook relay merged in #2. Both
hosts need the same value: jupiter signs deliveries with it (the webhook
config registered by gitea-hermes-webhook-provision), and mars verifies them
in the relay and hands it to Hermes through the hermes-agent.env template.

32 random bytes, hex-encoded, stored with no trailing newline — the value
reaches Hermes via an env-file template where a newline would both corrupt
the file and change the key the HMAC is computed with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-23 05:18:58 +02:00
darman bdd6107be1 Merge pull request 'mars: add generic Gitea webhook relay' (#2) from feat/mars-gitea-webhook-relay into master
Reviewed-on: #2
Reviewed-by: darman <mail@erik-s.dev>
2026-08-23 05:14:35 +02:00
darmanandClaude Opus 5 b0e7e67c90 relay: copy X-Gitea-Event into X-GitHub-Event, fix deploy ordering
The relay was forwarding X-Gitea-Event and re-signing the body into the
deprecated generic-V1 X-Webhook-Signature header. Neither is something
Hermes acts on, which left the PR's core premise — "Hermes owns event
selection" — impossible to reach:

  - Hermes reads the event name only from X-GitHub-Event/X-GitLab-Event,
    then payload event_type/type, then falls back to the literal string
    "unknown" (gateway/platforms/webhook.py). Gitea sends X-Gitea-Event and
    no such payload key, so every delivery arrived as "unknown" and
    `hermes webhook subscribe --events ...` could never select anything.
  - Gitea's addDefaultHeaders() already signs every webhook type with
    X-Hub-Signature-256 in GitHub's exact format, and Hermes accepts that
    header on any route with no per-route provider gating. Re-signing into
    V1 was both redundant and on a deprecated path.

So the relay now verifies the signature (accepting either X-Hub-Signature-256
or X-Gitea-Signature), forwards body and signature byte-for-byte, and copies
the one header Hermes actually needs. Authentication alone never justified
this service; that header copy does, and the module comment now says so.

Also fixed:
  - gitea-hermes-webhook-provision had no API readiness wait, unlike both
    sibling units in the same file. After=gitea.service does not mean gitea
    is serving HTTP, so under `set -e` a Type=oneshot with no Restart= would
    fail on first boot and stay failed, leaving the webhook unregistered.
  - podman-hermes-agent added to the secret's restartUnits. The secret
    reaches the container only via sops.templates, whose rendered path never
    changes, so systemd would not restart the container when the secret was
    first added — hermes-agent-webhook-route then read an empty value back
    out of it and subscribed with an empty secret.
  - Webhook provisioning passes the request body to curl on stdin rather
    than in argv, keeping the shared secret out of /proc/<pid>/cmdline.
  - Missing Content-Length now returns 411 rather than 413; dropped the
    unreachable non-2xx branch (urlopen raises on non-2xx); env-var secret
    fallback is stripped to match the credential-file path.

Adds gitea-hermes-webhook-relay-test.py, which drives the real relay over
real HTTP against a stub Hermes and covers the header copy as a regression
test. Both nixosConfigurations still evaluate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-23 05:02:55 +02:00
luna 6a037d557c relay: forward raw Gitea events unchanged 2026-08-23 02:31:32 +00:00
luna 7b36d95293 relay: defer event policy to Hermes 2026-08-23 01:59:20 +00:00
luna 806cec77e8 mars: add generic Gitea webhook relay 2026-08-23 01:45:18 +00:00
luna ea9be6fb8a mars: add VictoriaMetrics monitoring 2026-08-23 00:55:07 +00: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 b4917c0daa flake: update home-manager, nixos-images, nixpkgs-unstable inputs
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FHr5ug9pu8q4XPrRkFnzJ
2026-08-22 20:50:21 +02:00
darmanandClaude Sonnet 5 dc83e8c156 add node_exporter host vitals + quickshell HUD
Prometheus node_exporter enabled on every host, plus a quickshell widget
(SUPER+CTRL+V on terra) to view live CPU/mem/disk/net/uptime without a
separate dashboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FHr5ug9pu8q4XPrRkFnzJ
2026-08-22 20:50:16 +02:00
darmanandClaude Sonnet 5 60bef752cb home-manager: enable for darman on every host, not just terra
terra was the only host with real ~/.zshrc/.zshenv (via home-manager),
so it never hit the plain-zsh zsh-newuser-install wizard that shows up
on first login everywhere else. Wire home-manager.nixosModules.home-manager
into jupiter/neptun/mars/mercury (+ mercury-vm/jupiter-vbox test targets)
and give darman terra's shared zsh baseline via home/common.nix, imported
from common.nix. terra's own home.nix now only carries its
desktop/dev-specific profile (Hyprland, alacritty, git identity, direnv,
dev packages) layered on top via home-manager.users.darman.imports.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du1WQRk1F8DenrPhf8TofF
2026-08-22 05:03:43 +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 99501ce7d2 mars: drop idle-timeout on the /mnt/jupiter cifs mount
podman-hermes-agent.service RequiresMountsFor /mnt/jupiter, but the mount
option copied from terra's (read-only, nothing depends on it) browsing
mount included x-systemd.idle-timeout=60 — confirmed on real hardware,
this killed the container ~60-70s after every start with no crash or
error, just an idle auto-unmount taking the dependent service down with
it. Keep the lazy x-systemd.automount (so boot doesn't stall if jupiter's
down) but drop the timeout now that something needs the mount to persist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FHr5ug9pu8q4XPrRkFnzJ
2026-08-22 03:09:21 +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
darmanandClaude Sonnet 5 9403122888 jupiter: fix Hermes cron scheduler defaulting to UTC
The container has no host /etc/localtime bind-mount, so hermes_time.py's
timezone resolution fell through to UTC. HERMES_TIMEZONE is its
highest-priority source (checked before config.yaml's timezone key).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 23:00:55 +02:00
darmanandClaude Sonnet 5 713d91d5fc terra: finish removing Hermes Agent (module import + secrets)
Follow-up to e5ba1bf — that commit only staged the deleted module file.
Drops the flake module import, opencode_go_api_key/telegram_bot_token
secrets, and the stale hermes-agent.nix cross-reference in ollama's
context_length comment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 22:49:50 +02:00
darmanandClaude Sonnet 5 e5ba1bfc55 terra: remove Hermes Agent
Consolidating on jupiter's isolated instance (hosts/jupiter/hermes-agent.nix)
instead of running a second one here. Drops the module import, its
opencode_go_api_key/telegram_bot_token secrets, and the now-stale
cross-reference in ollama's context_length comment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 22:49:38 +02:00
darmanandClaude Sonnet 5 b5fa599671 jupiter: add isolated Hermes Agent instance
A separate instance from terra's, deliberately locked down harder given
jupiter's much bigger blast radius (irreplaceable immich photos on an
unredundant RAID0, gitea/CI tokens, the whole media stack): its own
dedicated "hermes" system user rather than darman (who is in jupiter's
root-equivalent docker group), container.enable = true for whole-process
containment rather than native/bare-metal, its own Telegram bot + explicit
allowlist, and no volume access to /mnt/data or this repo. stateDir/
workingDirectory live on the array (off the 29G eMMC) for future coding-task
state, guarded by RequiresMountsFor like the rest of jupiter's array-backed
services.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 22:46:32 +02:00
darmanandClaude Sonnet 5 0fa567245a jupiter: GC on every boot, silence mdadm warning, migrate sabnzbd off reused ini
nix.gc (common.nix) is weekly, too slow to catch a switch pinning the old
generation's closure on a 29G eMMC — add a full nix-collect-garbage on every
boot instead. Also set boot.swraid.mdadmConf so eval stops warning that
mdmon will crash (dormant here: the RAID0 array uses native superblocks, so
mdmon never actually runs).

sabnzbd.configFile is deprecated by the module; move to services.sabnzbd.settings
with credentials (web login, api/nzb keys, eweka.nl server) sourced from sops via
secretValues instead of living in a plaintext ini. admin_dir/log_dir are pinned
absolute at their original /mnt/data location so the existing download
queue/history isn't reset by the ini moving to /var/lib/sabnzbd.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 20:58:40 +02:00
darmanandClaude Sonnet 5 969bd69d8d terra: add Hermes Agent, wired to local ollama
Points Nous Research's Hermes Agent at terra's own ROCm ollama server
(gemma4:12b) as a custom OpenAI-compatible provider instead of a cloud
key. Native systemd mode via the hermes-agent flake's own NixOS module
— simpler than container mode, avoids the podman-rootful-sudo dance
its docs call out.

Also bumps OLLAMA_CONTEXT_LENGTH (and Hermes' mirrored model.context_length)
from ollama's ~4k default to 131072, load-tested with real multi-ten-
thousand-token prompts rather than just idle `ollama ps` checks — chosen
as the practical ceiling where VRAM headroom and prefill throughput both
start visibly degrading, not just the largest number that technically fit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 01:28:34 +02:00