Commit Graph
150 Commits
Author SHA1 Message Date
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
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
darmanandClaude Sonnet 5 6c8046bac8 jupiter: VAAPI hardware transcoding for jellyfin, move heavy state off the eMMC
Enables hardware.graphics + intel-media-driver for the Apollo Lake's
Gen9 iGPU (VAAPI only — QSV needs an insecure/EOL runtime on this
chip) and adds jellyfin's service user to video/render for the DRI
card node. 4K HDR still can't be tone-mapped on this hardware; those
files need to direct-play or be kept as 1080p SDR.

Also relocates podman's container storage and immich's postgres
cluster to /mnt/data/AppData, after a deploy holding two ~9G closures
at once filled the 29G eMMC and postgres died mid-write.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 00:38:22 +02:00
darmanandClaude Sonnet 5 914a7e5105 terra: drop kicad flatpak
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 00:38:15 +02:00
darmanandClaude Sonnet 5 f09ba07b63 docs: warn against printing decrypted sops secrets
Running sops --decrypt/edit_secrets --show and displaying the result
puts every plaintext secret in the file wherever that output lands,
not just the one value being checked. Point at `sops --set` instead
for adding/changing a single value non-interactively.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 00:38:12 +02:00
darmanandClaude Sonnet 5 7904433d34 terra: ollama (ROCm) + LibreChat with local persistent memory
Local LLM server on the 6800 XT (ollama-rocm, gfx1030 needs no
HSA_OVERRIDE_GFX_VERSION) fronted by a LibreChat web UI, talking to it
over the OpenAI-compatible /v1 route. Also wires up LibreChat's
persistent-memory feature, which needed its own agent+model plus a
custom extraction prompt: the default 3b model couldn't reliably tell
the user's stated facts apart from its own boilerplate, and even a
tuned prompt didn't fix that — so memory extraction now reuses
gemma4:12b, the same model as the daily-driver chat endpoint.

flake.lock bump pulls in the ollama and librechat NixOS modules.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 00:38:07 +02:00
darmanandClaude Opus 5 0ec3c6413c jupiter: keep booting when the data array is missing
After the rack move one of the RAID0 disks failed to enumerate, and jupiter
boot-looped into an emergency shell nobody could use — root is locked, so
sulogin offers a prompt with no answer, and there is no ssh from there:

  Timed out waiting for device /dev/disk/by-uuid/dadbff6f-...
  Dependency failed for /mnt/data.
  Dependency failed for /var/lib/private/prowlarr.
  Dependency failed for Local File Systems.
  local-fs.target: Job local-fs.target/start failed with result 'dependency'
  Reached target Emergency Mode.

`nofail` on /mnt/data did not help, because the prowlarr and seerr bind
mounts layered on top of it had none: without it a mount is RequiredBy
local-fs.target, so those two failed the target on the array's behalf. Give
them `nofail` too and let them fail alone. `systemd.enableEmergencyMode =
false` then keeps a bad array from costing a reachable box at all — far more
useful on a headless host than a console prompt.

Booting further is only safe if nothing quietly relocates onto the 29G eMMC,
so pin the array-backed services to the mount. systemd derives
RequiresMountsFor from a unit's own paths, which for these is somewhere under
/var/lib (eMMC) — nothing pointed immich at mediaLocation or sabnzbd at its
configFile, so with the array gone they would have started and written to the
OS disk, into directories that go invisible the moment /mnt/data mounts over
them. jellyfin, sonarr, radarr and gitea already had a real dependency and
are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:32:42 +02:00
darmanandClaude Opus 5 5a4588c532 gitea: provision a ci-bot account with repo + branch-protection access
Workflows push as a dedicated ci-bot account rather than a human one, so its
PAT can be scoped, rotated and revoked on its own. Adding a repo to
`ciBotRepos` and redeploying is all it takes to grant access.

Collaborator access and branch-protection push-whitelisting exist only on
gitea's HTTP API — no CLI, no config-file surface — so this one part stays
imperative: a oneshot that PUT/PATCHes the API into the desired state. It
runs on deploys where the script changed, which means it won't self-heal a
revert done through the web UI unless the unit is restarted too.

Two secrets, deliberately distinct:
- gitea_provisioning_token is darman's own token (write:repository +
  write:user). Only an owner-scoped token clears reqOwnerCheck on the
  collaborator and branch-protection endpoints, and write:user is what lets
  it write the Actions secret below. ci-bot cannot grant itself access.
- gitea_ci_bot_token is ci-bot's push token, generated once by hand (the
  command is in the comment) and pushed into gitea as a user-level Actions
  secret CI_BOT_TOKEN. Gitea has no instance-wide secret scope, and every
  repo here is owned by darman directly rather than an org, so a user-level
  secret is the closest thing — repo-level lookups fall back to it.

Branch protection is applied to the default branch plus `develop`, since
version-bump.yml pushes there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:32:28 +02:00
darmanandClaude Opus 5 c17524e358 terra: hyprchrome glow/shadow, borderless windows, warmer accents
Now that the plugin draws its own outline and glow, hyprland's own border is
redundant — border_size 0 and let hyprchrome own the window edge (outline_size
2 in fg_color). Fill in the rest of its knobs: glow 12/0.85, shadow 24 offset
{4,8} in bg_color.

Accents go warm: bg_accent to a muted red (963c38) and a new fg_accent_alt
(ff9d42) so the active-border gradient runs amber->orange instead of
amber->background. Colour literals lowercased for consistency.

Also re-pick the placeholder wallpaper, and drop the comment explaining
hyprland's lua gradient table format — the surviving call site is now the
only one and reads plainly enough.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:32:16 +02:00
darmanandClaude Opus 5 2abd842e97 terra: tome, rootless podman for GPU containers, direnv, kicad
- install tome from pkgs/tome.nix, built against the re-added flake input
- import services/containers.nix and put darman in `render`/`video`:
  /dev/dri/renderD128 is root:render 0660, so a rootless container can only
  reach the GPU if the host user is in the group. Needed by the Vulkan
  whisper.cpp/llama.cpp containers in content-trigger-scanner.
- point DOCKER_HOST at the podman *user* socket and add docker-compose.
  dockerCompat gives a `docker` CLI shim, but compose v2 is its own binary
  talking to a socket, and rootless podman's socket is the user one under
  /run/user/1000 — not root's /var/run/docker.sock.
- direnv + nix-direnv, so per-repo devShells load in the shell and in Rider
  via its direnv plugin, instead of hand-wiring a toolbox SDK per repo
- kicad as a flatpak, alongside the other flatpak desktop apps

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:32:06 +02:00
darmanandClaude Opus 5 d5507811ea tome: aspnetcore runtime, gapps wrapping, glib at runtime
Three separate reasons the packaged build didn't behave like the one Rider
launches:

- dotnet-runtime must be aspnetcore_10_0, not runtime_10_0. Tome.App's
  runtimeconfig.json requires Microsoft.AspNetCore.App as well as
  Microsoft.NETCore.App, because Photino hosts a local Kestrel server, and
  only the aspnetcore bundle ships it.
- wrapGAppsHook3, so gappsWrapperArgs get spliced into buildDotnetModule's
  own wrap step (it sets dontWrapGApps itself; same pattern as nixpkgs'
  libation). Without it nothing sets XDG_DATA_DIRS/GSETTINGS_SCHEMA_DIR, so
  GTK/WebKitGTK found neither the icon theme nor GTK settings — missing icons
  and a denser default font than in an already-initialized session.
- glib in runtimeDeps. It doesn't arrive via gtk3/webkitgtk's RPATH because
  the consumer is Photino.Native.so, a prebuilt binary out of the nuget
  package rather than something Nix built and patched.

tome-deps.json is the regenerated nuget lock for the aspnetcore switch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:31:56 +02:00
darmanandClaude Opus 5 f431e81ce6 tailscale: drop the 1.98.9 vendorHash override
c5a231b pinned the hash by hand because nixpkgs bumped 1.98.8->1.98.9 without
updating vendorHash (NixOS/nixpkgs#545860). The previous commit's lock moves
nixos-26.05 past the point where that fix was promoted from release-26.05, so
the override is now dead weight — and a stale vendorHash override is worse
than none, since it silently wins over a correct upstream value on the next
version bump.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:31:46 +02:00
darmanandClaude Opus 5 a172e49c7e flake: re-add the tome input, refresh locked inputs
Tome (formerly AudibleLibrary) comes back as a git+ssh input against our own
gitea, fetched with darman's ambient key. `flake = false` — it's a plain
source tree consumed by pkgs/tome.nix, not a flake of its own.

This re-breaks `./scripts/deploy install terra localhost` exactly the way
4f79ec7 removed it for: the installer-iso has no credentials, so the git+ssh
fetch fails at nixos-install, post-disko. Taking that tradeoff knowingly
rather than losing the app from the desktop config again — the note in
flake.nix spells it out for whoever hits it next.

The lock also picks up the routine input refresh, including the nixos-26.05
rev that finally carries the tailscale vendorHash fix (next commit) and a new
client-ts-generator-src node pulled in by authentik-nix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:31:37 +02:00
darman faaf24ddc0 terra: cosmic portals, jupiter smb mount, tea CLI, launcher/theme polish
- swap gtk portal/apps for cosmic (xdg-desktop-portal-cosmic, cosmic-files,
  cosmic-settings) and drop dolphin/protonplus/bambu-studio (bambu-studio
  moved to flatpak alongside the other comms/gaming flatpaks)
- mount jupiter's samba share at /mnt/jupiter (automount, credentials from
  the same samba_password secret jupiter itself uses)
- add tea (gitea's remote API CLI) for talking to git.mgaction.town from
  terra without SSHing into jupiter
- new dark icon themes (Amy, Azure Glassy, Slot Beauty) vendored from
  gnome-look.org tarballs, packaged since pling download links expire
- rishot: fix Qt5Compat.GraphicalEffects QML import (was missing qt6.qt5compat
  on QML_IMPORT_PATH, so quickshell failed at config-load)
- launcher widgets: stop LauncherConsole/LauncherDock from reserving
  compositor space (ExclusionMode.Ignore, they're overlays not real docks);
  bump LauncherCorner app icon size 28->34
- comms script: launch telegram/discord via flatpak, not native binaries
- nix-ld + boot.binfmt aarch64 emulation (for building/flashing mercury
  from terra)
2026-07-29 21:43:43 +02:00
darman 585aff3652 deploy: gc jupiter after every switch (eMMC space)
configurationLimit prunes generations beyond the cap as part of the switch,
but pruning only drops a generation as a GC root — the store paths
themselves still need an actual collect to free the disk. Do that right
after every jupiter switch rather than waiting up to a week for
gc.dates=weekly to matter again.
2026-07-29 21:43:28 +02:00
darman 93a4e09a73 flake: add hypr-chrome plugin input
Own Hyprland plugin (border + title bar), public repo on our own gitea,
fetched over https (no credentials needed). nixpkgs.follows keeps its build
ABI-correct — Hyprland plugins are ABI-locked to the exact Hyprland build
they load into, so it has to build against this flake's own nixpkgs rather
than whatever hypr-chrome's own flake.lock pins standalone.
2026-07-29 21:43:21 +02:00
darman c5a231baff tailscale: pin vendorHash for 1.98.9 (nixpkgs bump missed it)
TEMPORARY: nixpkgs bumped tailscale 1.98.8->1.98.9 without updating
vendorHash (NixOS/nixpkgs#545860, fixed on release-26.05 but not yet
promoted to the nixos-26.05 channel branch this flake tracks). Remove once
`nix flake lock --update-input nixpkgs` picks up a fixed rev.
2026-07-29 21:43:16 +02:00
darman 29ddd0cb7c neptun: stop processing router advertisements on eth0
Addressing is fully static, but netcup's router still sends periodic RAs on
this segment; the kernel then tries (and fails, since the static route
already exists) to install its own default route from them, spamming
"ndisc_router_discovery failed to add default route" on the console.
2026-07-29 21:43:11 +02:00
darman 63ca6f8409 jupiter: enable gitea Actions + register a jupiter runner
Runner registers against the same gitea instance and runs jobs in podman
containers (services/containers.nix), one image per runs-on label using the
catthehacker act-compatible images. Registration token comes from gitea
itself (gitea actions generate-runner-token) and is stored in
secrets/jupiter.yaml, rendered into a TOKEN=... env file via sops.templates
since gitea-actions-runner takes an EnvironmentFile, not a raw secret path.
2026-07-29 21:43:05 +02:00