Commit graph hyperhive/nix
Author SHA1 Message Date
atlas
747f405c6f fix(nix): keep the domain-derived defaults total
The required-domain assertion in hive-network.nix could not be reached:
`forge.<domain>` and `matrix.<domain>` are evaluated while the
assertion list is, so an unset domain threw `cannot coerce null to a
string` naming one of those options instead of printing the message
that says which option to set.

Both defaults now fall back to a name under the reserved `.invalid`
TLD, which the assertion refuses to let out the door.
2026-08-05 15:56:49 +02:00
atlas
ec565120af feat(nix): derive the hive domain from a new swarm.domain
Every hive in a swarm occupies its own sub-domain of the swarm's, so
the hive domain is derivable rather than something each hive restates.
`services.hyperhive.swarm.domain` is new and nullable; the hive's own
`domain` keeps its existing required-ness and its existing assertion,
and gains a default of `<hiveName>.<swarm.domain>`.

Deliberately a default and not a rename: an alias would reinterpret the
domains hives have already deployed, while a default only fills in the
ones that never set one. Same reason there is no new assertion — the
existing message just names the derivation, so this adds a way to stop
failing rather than a way to fail.
2026-08-05 15:56:49 +02:00
atlas
fb51006717 fix(nix): swarm-controller is not a core binary, and it needed a README
Per review: daemonBins is the core stack, and it drives the bundle that
services.hyperhive.c0re.package points at -- so listing a swarm-scoped
service there would put it in every hive's closure when one hive in a
swarm runs it. It gets the same per-bin extractor, bound on its own, the
way hivectl already is.

The crate also had no README while every other one does. Both misses are
the same shape: adding a thing without updating what describes the set of
things.
2026-08-05 14:59:37 +02:00
atlas
435dfbfb33 feat(nix): swarm-controller systemd unit, service user and socket
services.hyperhive.swarm.controller.{enable,package,socketPath} plus the
unprivileged swarm-controller user, its runtime and state directories,
and the unit itself.

enable is deliberately not derived from services.hyperhive.enable, unlike
c0re: a swarm has one controller, so turning it on is a statement about
swarm topology rather than about whether hyperhive is installed.

The socket gets its own RuntimeDirectory. nginx reaches a unix upstream
by having the socket's directory bind-mounted into the gateway
container, and the socket is 0666 because connect needs write -- so the
directory is the only access control there is. Sharing one with the host
admin socket would hand that socket to the gateway too. The constraint
is stated at both ends, in the option description and beside the bind,
because it is invisible from either site alone; a test pins the path so
a tidying edit fails rather than reviews cleanly.

RuntimeDirectoryPreserve and the daemon's stale-socket unlink are a
pair: preserving the directory without the unlink means bind fails with
EADDRINUSE after a restart.
2026-08-05 14:59:37 +02:00
atlas
898dde7402 feat(swarm-controller): new crate, a unix-socket listener and nothing else
First half of the swarm-controller slice: the crate, its workspace entry
and its daemonBins entry, so the systemd unit that follows has a binary
to point at.

It serves one health endpoint and owns no state. That is the whole
intent -- this makes the unit real (service user, runtime and state
directories, socket, nginx reachability) so the swarm-level surfaces
that follow have somewhere to land. Inventing those surfaces now would
bake in a shape nobody has agreed to.

The socket gets its own runtime directory rather than sharing
hive-c0re's. nginx reaches a unix upstream by having the socket's
directory bind-mounted into the gateway container, so co-locating this
socket with the host admin socket would hand the gateway that socket
too.
2026-08-05 14:59:37 +02:00
atlas
fcdff04b23 refactor(nix): move the matrix host options under services.hyperhive.swarm
Second slice of the swarm-global service consolidation, same shape as
the forge move: the operator-facing host options become
services.hyperhive.swarm.matrix.*, and one mkRenamedOptionModule on the
namespace carries the whole subtree (nested gui.* included), so there is
no leaf list to forget an entry from.

The rename lives in hive-matrix.nix, the module that declares the
options, so each service's migration stays independent of its siblings.

The per-agent hyperhive.matrix.{enable,url} and hyperhive.matrixAccounts
are a different namespace -- a client pointer at the service, not the
service -- and deliberately do not move.
2026-08-05 13:45:09 +02:00
atlas
1a0cb0fb44 docs: name the swarm display name by its new path
Two sites spelled it as a brace group, services.hyperhive.{hiveName,
swarmName}, which no anchored rewrite can handle correctly now that only
one of the two moves; both are written out separately. One of them is an
MCP tool description, so it is rendered into every agent's system prompt.
2026-08-05 11:15:41 +02:00
atlas
30a94d35e9 refactor(nix): move the swarm display name under services.hyperhive.swarm
services.hyperhive.swarmName becomes services.hyperhive.swarm.name, with
one mkRenamedOptionModule in hyperhive.nix -- the module that declares
it, same convention as the forge and matrix renames, so each migration
stays independent of its siblings.

hiveName deliberately stays where it is. It names this hive; swarm.name
names the group the hive belongs to, and that they now sit one level
apart is the distinction rather than an inconsistency.

The per-agent hyperhive.swarmName is an internal mirror rendered from
the host value and does not move, same split as forge and matrix.
2026-08-05 11:15:41 +02:00
atlas
0d23896dbb fix(nix): the rename lives with the module that declares the options
mara: "remove the extra deprecatios file, less comments". The alias now
sits in hive-forge/default.nix next to the options it renames, and the
comment is three lines instead of eighteen.

Better than tidier: there is no shared file for the sibling migrations
to queue behind. Matrix's rename goes in hive-matrix.nix, authelia's in
its own module, and none of them touch each other. hive-ci.nix declares
swarm.forge.ci.* from a different module and stays covered, because the
rename is on the namespace rather than on the declaring file.

Re-ran the check both ways this time, per argus on the last round: old
paths set and new paths read back 3999 / 7, and a config that only uses
the new paths produces no warnings at all.
2026-08-05 03:44:53 +02:00
atlas
f7beea3a6f fix(nix): rename the forge attrset, not fifteen leaves
mara on the PR: "why deprecate individual keys instead of the whole
attrset? there is no hyperhive.forge anymore after the move". Right, and
mkRenamedOptionModule takes the namespace path directly - the subtree
comes with it, nested ci.* included.

Fifteen explicit renames collapse to one. The list was also a
liability: an option added under swarm.forge later would have needed
remembering here to get an alias, so it could silently fall behind the
options it mirrors.

Re-ran both evals against the new form. Values still map (httpPort 3999,
ci.concurrency 7 through the old paths) and the warning still fires; it
now names the attrset rather than the leaf, which also means one warning
per hive instead of one per option an operator had set.
2026-08-05 03:44:53 +02:00
atlas
38c222fadd feat(nix): move the forge host options under services.hyperhive.swarm
Forgejo is a swarm-global service, so its operator-facing host options
move to services.hyperhive.swarm.forge (and .swarm.forge.ci) as the
first of the namespace consolidation.

Existing hive configs keep evaluating: swarm-renames.nix maps every
moved leaf with mkRenamedOptionModule, which also emits a deprecation
warning naming both the old and new path, so an operator is told what to
rename rather than discovering it from a failed eval.

The per-agent hyperhive.forge.url does NOT move. It is a client pointer
at whatever forge an agent talks to - it shares a word with the service
and nothing else, and the two are already documented as separate option
surfaces.

Verified by evaluating the host module, since no Rust gate evaluates
nix: setting the old paths and reading the new ones yields the values
(httpPort 3999, ci.concurrency 7), and config.warnings carries the
rename notice.
2026-08-05 03:44:53 +02:00
atlas
3fc1588e83 fix: declare the agent socket dir's owner in tmpfiles, not by chown after
/run/hive-agent/<name> was 0777 root root in steady state, not just during
first spawn. A directory without the sticky bit lets any user unlink files
in it, and the gateway container has all of /run/hive-agent bind-mounted
in, so anything that could reach the path could delete an agent's
agent.sock, bind its own, and receive that agent's todos from hive-c0re.

Two mechanisms were writing the dir and undoing each other: the tmpfiles.d
entry wrote 0777 root root, then hive-c0re round-tripped through hive-priv's
ChownSocketDir to narrow it. `d` re-asserts mode and owner on every apply
and the file is regenerated on any agent's spawn or destroy, so every such
event reset every agent's dir back to world-writable.

SyncAgentTmpfiles now carries each agent's container uid/gid and the entry
declares the answer: 0751 <uid> <gid>. Three principals need the dir and no
two share a group -- the harness binds its sockets (owner rwx), hive-c0re
dials agent.sock and the gateway's nginx dials web.sock (both only need
traverse, and both sockets are already 0666).

Deletes ChownSocketDir and ChmodSocketDir, both priv_client wrappers, the
either/or in host_config with its two swallowed warn!s, and the now-dead
socket_dir_path -- two verbs off the privileged helper's surface and one
round-trip off every agent spawn.

Also makes the two tmpfiles rules for /run/hive-agent itself agree: the
gateway module said hive-core, the generated file said root, and which won
depended on the order systemd read them in.
2026-08-04 01:00:48 +02:00
atlas
bd06f81294 wip(#2860): c0re-facing matrix.apiUrl option + HIVE_MATRIX_API_URL export
Nix half of the 4th layer mara found (47535: core cannot assume matrix
is on localhost). Rust half (matrix.rs MATRIX_HTTP) NOT done.

Parked here rather than left dirty: she has redirected me to jobq as
prio 1, and uncommitted files migrate across a checkout.
2026-08-03 20:34:36 +02:00
atlas
0e9b1c563d fix(#2860): no loopback default for the matrix homeserver
Third and last of #2860's agent-facing URL fallbacks. The operator's
ruling was "any special casing is done on the nix side - same binaries,
no hard coded fallback", so the default is deleted rather than replaced.

Every layer guessed the same wrong thing, and each guess was only ever
correct for a process sharing the host netns:

- nix/agent-modules/matrix.nix: matrixUrlDefault = localhost:8008, both
  as the option's default and as a sentinel the daemon unit compared
  against to decide whether to write HIVE_MATRIX_URL. Now nullOr str,
  default null, the guard is != null, and the doc says what forge.url's
  already says: null means "no matrix", not "guess one".
- nix/host-modules/hive-c0re/environment.nix: forwarded
  http://127.0.0.1:<port> when no gatewayHost was set. hive-c0re shares
  the host netns so it reads as harmless, but the value is handed to
  agents, which do not -- there it names the agent itself. Now forwarded
  only when there is a gateway vhost to name, matching the guard
  HIVE_MATRIX_PUBLIC_URL already uses twelve lines below.
- hive-matrix-mcp: paths::DEFAULT_HOMESERVER was the same address
  compiled in, so dropping the nix defaults alone would have left the
  daemon dialling loopback inside the agent's own netns -- the very bug,
  one layer down. homeserver_url() is now Option, and an account with no
  homeserver is skipped with a log, exactly as one with no token is.
  discover_token_accounts already refused to guess for the same reason.

Two comments taught the assumption back to the next reader ("shared host
netns means every agent container resolves localhost to the same
machine"); both now say which side of the netns boundary they describe.
MATRIX_HTTP keeps its value -- hive-c0re really does share the host
netns -- but no longer claims agents do.

Gated with nix eval against the extended agent-base config, as a pair:
with no url set the daemon unit carries no HIVE_MATRIX_URL, and with one
set it carries exactly that. Either check alone passes on a broken guard.
2026-08-03 20:34:36 +02:00
iris
3512e4b019 dashboard: hide forge links instead of guessing <hostname>:3000
Adds services.hyperhive.forge.publicUrl (defaults to the gateway vhost
URL when behindGateway=true, null otherwise). HIVE_FORGE_PUBLIC_URL is
now sourced from it instead of hardcoding https://${forge.domain}
whenever behindGateway is on.

The 4 frontend call sites that built a forge link from
state.forge_public_url now hide the link when that's absent, rather
than guessing http://<browser-hostname>:3000 — a guess that's only
correct by accident once the operator isn't on plain localhost. Fixes
the dashboard H0M3 tile, per-agent-row forge links + agent menu, the
approval-queue PR link, and the per-agent page's own meta-nav forge
link (found during this pass, same defect, not in the original
3-site inventory).

Docs + doc-comments updated to match.
2026-08-03 01:21:11 +02:00
iris
9efe6f64b6 move swaggerUiTheme nix option out of c0re's namespace
services.hyperhive.c0re.swaggerUiTheme -> services.hyperhive.gateway.swaggerUiTheme.
hive-c0re has no relationship to this option (it's consumed entirely
by the gateway's nginx config) since the previous two commits moved
the theme files out of hive-c0re/ and gateway to hosting the whole
dist itself — the option shouldn't live in c0re's namespace either.

Pure option move: default wiring in flake.nix now sets
services.hyperhive.gateway.swaggerUiTheme instead of the c0re one;
hive-gateway/default.nix reads it via the module's own already-bound
cfg (services.hyperhive.gateway) instead of reaching into c0re's.

Verified: nix build .#swagger-ui-theme unaffected (same store path,
pure option-plumbing change, no content change); a full nixosSystem
eval of nixosModules.default resolves the option at its new path and
confirms the gateway's /api/docs/ location still aliases the correct
store path.
2026-08-02 21:24:57 +02:00
iris
3ef166738c move swagger-ui-theme/ out of hive-c0re/
hive-c0re doesn't touch these files anymore (nginx hosts them
directly, see the previous commit) — hive-c0re/swagger-ui-theme/ was
a leftover from the original SWAGGER_UI_OVERWRITE_FOLDER build-hook
design, which this PR moved away from. New location matches the
existing top-level-directory-for-nix-packaged-assets convention
(branding/, claude-plugins/).

Pure rename, no content change: nix/packages/swagger-ui-theme.nix's
three file references updated, everything else picks it up from the
same content hash (nix build .#swagger-ui-theme resolves to the exact
same store path as before the move).
2026-08-02 21:24:57 +02:00
iris
1bc9c18504 gateway: nginx-hosts the full Swagger UI dist, core drops the fallback
Extends the theme-only alias into the full shape mara asked for on the
PR thread:

1. nix/packages/swagger-ui-dist.nix — plain vendored Swagger UI 5.17.14
   dist, sourced directly from the swagger-ui-dist npm package (same
   release the Rust utoipa-swagger-ui-vendored crate ships, verified
   via matching gitHead commit) rather than through Cargo.lock/cargo.
2. nix/packages/swagger-ui-theme.nix — overlays our 3 override files
   (index.html, hyperhive-theme.css, and now swagger-initializer.js)
   onto (1).
3. vhosts.nix's swaggerUiLocations now prefix-matches the whole
   /api/docs/ tree (not just 2 exact-match files) straight from (2),
   plus a `= /api/docs` redirect shim since hive-c0re's own redirect
   is going away too. /api/openapi.json (outside this prefix) keeps
   proxying to c0re unchanged — that's the one thing that stays
   dynamic.

New file swagger-initializer.js needed hand-verification: the plain
vendored copy hardcodes the swagger.io petstore demo URL.
utoipa-swagger-ui normally rewrites it per-request from a {{config}}
placeholder its own build.rs injects — since hive-c0re won't be
serving this file at all once its SwaggerUi mount is removed, that
rewrite has to be baked in statically here instead. Derived by
actually running build.rs's own two transforms (strip the default
layout: line, splice the Config JSON in place of the url/deepLinking
block) against the real vendored file, not typed from scratch —
verified byte-for-byte against what format_config() would produce for
hive-c0re's actual single-URL config, and checked with node --check.

Coordinated with damocles: he's taking the corresponding hive-c0re
side (drop the utoipa-swagger-ui dependency + SwaggerUi::new(...)
mount, keep only the plain /api/openapi.json route) once this lands.

Verified: nix fmt clean; nix build .#swagger-ui-theme succeeds, output
byte-matches the checked-in override files and node --check passes on
swagger-initializer.js; a full nixosSystem eval of nixosModules.default
resolves both new locations (/api/docs/ aliased to the right store
path, = /api/docs redirecting) with auth threaded through.
2026-08-02 21:24:57 +02:00
damocles
de1659d10e hive-gateway: serve the swagger-ui theme via nginx alias, not a c0re build hook 2026-08-02 21:24:57 +02:00
damocles
3617578341 web_ui: scope /cancel and /logout's SIGINT to the harness's own claude child 2026-08-02 13:10:34 +02:00
damocles
913aeb385e hive-forge: explicitly allow migrations from any domain, not just local networks 2026-08-02 01:54:17 +02:00
atlas
772482a52a fix(#2898): carry hive/swarm display names as build-time options
The OTEL resource attributes are baked into every agent's
managed-settings.json at evaluation time, but claude-settings.nix read
the names from the container's environment.variables - where they are
never set. meta.rs forwards them as runtime env only, so the reader hit
its "unknown" fallback and every agent shipped

    service.name=hyperhive-agent,agent=<a>,hive=unknown,swarm=unknown

on every metric, while the same process's env held pr1ma/constellation.
Measured in this container's /etc/claude-code/managed-settings.json.

The map from forwarded env var to agent option already existed for the
service URLs, with a doc comment naming this exact hazard: "setting only
one leaves the other on its default". The names were simply never added
to it. They are now, and the constant is renamed FORWARDED_VAR_OPTIONS
since it no longer holds only URLs.

hyperhive.hiveName / hyperhive.swarmName follow the forge.url shape:
nullOr str defaulting to null, where null means the hive did not name
itself and "unknown" is an honest label rather than a guess baked at
eval time.

Also fixes, unasked: CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX reads the
same value, so remote-control sessions were named "unknown-<agent>".

Gated with nix, not just cargo - the blast radius here is module eval,
which fmt/clippy/test cannot see:

  agent-base assertions            -> [] (no failures)
  extendModules with both options  -> hive=pr1ma,swarm=constellation

Note the value is baked, so every agent needs a rebuild before the new
label appears on its metrics.
2026-08-01 12:35:50 +02:00
atlas
bcb9e837f7 fix(#2860): make hyperhive.forge.url nullable instead of guessing a URL
The option had a `http://localhost:3000` default, which is only ever
correct when the forge shares the caller's network namespace — inside an
agent's netns `localhost` is the agent, and the forge may well be on
another host. Making it *required* instead was worse: the flake's own
container configs are what hive-c0re extends per agent, so the value
they needed in order to evaluate became a second definition on every
agent and collided with the real one.

`null` resolves both. It is not a URL, so nothing can quietly talk to
the wrong machine, and it needs no placeholder anywhere: the bases
evaluate as they are, so nothing deployment-shaped sits on the config
agents inherit from. The units that would consume the URL — tea-login
and forge-avatar-sync — are simply not generated without one, making an
absent forge an absent integration rather than a misdirected one.

hive-forge-notify is unaffected: it reads HIVE_FORGE_URL from the
forwarded global environment, not from this option.

Verified:
  agent-base/ruth evaluate with forge.url = null, zero failing assertions
  bare base:      tea_login_present = false, avatar_present = false,
                  notify_present = true
  extended with a rendered URL: FORGE_URL=http://forge.real.test

Refs #2860
2026-08-01 00:36:09 +02:00
atlas
7ef9905e9f fix(#2860): make hyperhive.forge.url required, drop the loopback default
The default `http://localhost:3000` was a guess, and a guess that
evaluates is worse than one that doesn't: the forge may run on a
different host from the agents, and inside an agent's network namespace
`localhost` reaches the agent itself, not the forge. hive-c0re renders
this option into every agent's config from the host's `HIVE_FORGE_URL`,
which `hive-c0re.nix` sets unconditionally, so nothing legitimate was
relying on the default to be there.

Also tightens the URL assertion, which accepted `""`. That escape hatch
only existed because the default made the empty case unreachable; with
the default gone, `""` is exactly what a caller supplies when they have
nothing, so it now fails naming the option instead of silently building
a config that talks to nowhere.

Refs #2860
2026-07-31 22:53:51 +02:00
atlas
ba71e45486 refactor(#2862): one snapshot store per swarm, not one per peer
The push side modelled a store per peer hive: a --peer argument, a
swarm.peers.<domain>.snapshotStorePort option, and a swarm_peers module
whose entire job was answering "which peer". A swarm has exactly one
store, so none of that had anything to select between.

The receiver already proved it. It keys destination directories by
agent, not by sending hive, precisely so an agent that migrates keeps
one unbroken incremental chain -- which only makes sense if every hive
pushes to the same place. Per-hive stores would split the chain in two,
the case that keying exists to prevent.

So the destination moves to services.hyperhive.swarm.snapshotStore,
rendered into HYPERHIVE_SNAPSHOT_STORE, and swarm_peers is deleted
rather than adapted. address has no default because it is a
deployment fact this host cannot derive; port defaults because it is a
convention both ends read from the same option docs. An unset or empty
address fails naming the option instead of connecting somewhere
arbitrary, and a test asserts the message suggests no value.
2026-07-31 22:15:37 +02:00
atlas
282bbc3709 feat(#2862): push a snapshot to a peer hive's store over the mesh
Adds the caller the fd-passing machinery existed for: hivectl agent
<name> subvol snapshot push --peer <hive> resolves the peer, connects
to its snapshot store, writes the agent header, and hands the connected
socket to hive-priv, which runs btrfs send straight into it.

The split keeps the root helper ignorant. Everything that involves
knowing where a peer is, what the wire protocol looks like, and which
hive to trust happens in the unprivileged daemon; hive-priv only ever
receives an already-open descriptor. Once btrfs send starts, neither
process is in the data path, so a multi-gigabyte transfer costs no
per-byte work and survives a hive-c0re restart.

call_with_fd takes the descriptor by value and closes it as soon as the
kernel has it. A socket stays open until every copy closes, so holding
one back would leave the receiver waiting for an EOF that never comes:
btrfs receive blocks and this side reports success for a transfer the
peer never committed. Ownership makes that unrepresentable.

The peer's store port is a new swarm.peers.<domain>.snapshotStorePort
option rather than a constant matching the module default. A pushing
hive cannot read the receiver's configuration, so assuming 51821 would
push at a port nobody promised to listen on; absent, the push fails
naming the option. swarm_peers parses the mesh address the host module
has always rendered into HYPERHIVE_PEERS but nothing read.
2026-07-31 22:15:37 +02:00
damocles
b39bf67cb3 add /health/live and /health/ready hive-wide health endpoints 2026-07-31 21:01:23 +02:00
atlas
6a6266cd5e refactor(#2862): keep the option at services.hyperhive.snapshotStore
Reverting the namespace move from the previous commit — mara's reason
is better than mine was.

I grouped it with swarm.peers and swarm.wireguard because the module
serves the swarm tier. But those two describe THE SWARM: who is in it,
how it is meshed. snapshotStore describes THIS HOST'S ROLE. On a
standalone store box the operator enables one service, and nesting it
under `swarm` implies they are configuring a swarm when they are not.

The swarm- prefix on the file and units stands: the name says which
tier the component serves, the option path says what you are turning
on. Those are different questions and they are allowed different
answers.
2026-07-31 19:03:24 +02:00
atlas
70bcdb5463 refactor(#2862): swarm- prefix for the snapshot store
mara, in preparation for the swarm tier: the store is a swarm-level
role, not a hive one, so hive- was misleading about which tier it
belongs to. Module, units, syslog identifier, log lines and docs all
move to swarm-snapshot-store.

Also moved the option under services.hyperhive.swarm.snapshotStore, to
sit with swarm.peers and swarm.wireguard rather than dangling off the
top level. That is a judgement call beyond the literal rename — flagged
on the PR, and cheap precisely now: the option has never shipped, so
there is no deployment to migrate, whereas doing it after a release
would be a breaking change for no new benefit.
2026-07-31 19:03:24 +02:00
atlas
57459cb6d8 refactor(#2862): split the wireguard mesh out of swarm.nix
mara asked, and the file had already stopped being one thing: after
the gate moved off c0re.enable, swarm.nix held two concerns with
different audiences and different gates.

swarm.nix now declares WHO the peers are — data hive-c0re serialises
into HYPERHIVE_PEERS and the dashboard renders. Declaration only, no
config block.

swarm-wireguard.nix owns the mesh: assertions, the wg-hive interface,
the firewall port. That is plain host networking, and a machine which
runs no hive at all — the snapshot store — still needs it. Under the
old layout a reader could not tell which half of swarm.nix applied to
a non-hive host.

The two stay coupled by data, not by structure: the per-peer
wireguard* fields stay on the peer submodule, because that is where a
peer is described, and the mesh module reads them.

No behaviour change — same options, same gate, same rendered config.
2026-07-31 19:03:24 +02:00
atlas
c051cd9717 docs(#2862): document the snapshot store, drop the dedicated option
mara: the option was the wrong shape for the concern. "this host runs
nothing else" is a deployment expectation, not something a module
should assert about its own host — and asserting it made co-location
look like a config toggle rather than what it is.

Replaced with docs/snapshot-store.md, which the module had no docs
page at all before: enabling it, why the mesh is the authentication
(cryptokey routing already binds source address to pubkey, so certs
would authenticate the same fact twice and add an expiry), why the
destination is keyed per agent (a per-hive prefix splits an agent's
chain the first time it migrates), what the sender may and may not
choose, why the firewall rule is interface-scoped, what a snapshot
does and does not contain, and what the pull side still needs.

The dedicated-host expectation is stated there as an operational
assumption with its own failure mode — true on day one, quietly false
the day someone notices the box has spare disk — rather than as an
assertion someone flips to false to make the build proceed.

Linked from CLAUDE.md's reading paths.
2026-07-31 19:03:24 +02:00
atlas
4989579270 fix(#2862): open the receiver's port on the mesh interface
argus caught it: binding the socket to the mesh address does not open
the port. NixOS's firewall is default-deny and filters in netfilter,
before a packet reaches a bound socket — the bind chooses which
address accepts connections, not whether packets arrive. As shipped
the receiver was unreachable.

swarm.nix already shows the pattern for exactly this situation: it
opens the mesh's UDP port explicitly right after bringing the
interface up.

Interface-scoped to wg-hive rather than host-wide, so the option's
"reachable exactly by mesh peers" claim is actually true. A global
allowedTCPPorts would open the port on every interface including the
public NIC, leaving only the socket's bind address between the
internet and a root btrfs receive.
2026-07-31 19:03:24 +02:00
atlas
bdf8fdabd7 feat(#2862): swarm snapshot store, the btrfs receive endpoint
P1 of the storage backend: hives push agent snapshots over the
WireGuard mesh that swarm.nix already brings up. No controller
dependency — a btrfs subvolume tree, a socket-activated receiver, and
the existing mesh.

The mesh is the authentication. Cryptokey routing already binds a
peer's source address to its public key (allowedIPs = [
peer.wireguardAddress ]), so the store adds no key material and no
certs; anything else would authenticate the same fact twice.

Destination is keyed per AGENT, not per hive: after a migration the
same agent's next incremental send arrives from a different hive, and
a per-hive prefix would split its snapshot chain and break the
incremental parent lookup — the exact case this store exists to serve.

The sender unavoidably contributes the agent name (a btrfs stream
carries no such notion, and the subvolume name inside it is the
sender's). So the receiver owns the destination root and VALIDATES the
sender-supplied leaf against a whitelist charset — no slash, no dot,
so neither traversal nor an absolute path can survive it.

ListenStream binds this host's mesh address, never a wildcard, and
that is asserted rather than commented: bound to 0.0.0.0 the socket
would be an unauthenticated remote write into agent state.

swarm.nix: the mesh config moves off the c0re.enable gate onto
swarm.wireguard.enable. The mesh is host networking, not a c0re
feature — a swarm host that runs no hive (this store) previously got
no wg-hive interface at all. Nothing in that block was c0re-specific;
the peer data c0re consumes is rendered in hive-c0re and stays gated
there.

Confinement is deliberately not in the module: it is a property of the
deployment (a dedicated VM, or a container in the all-local case). The
systemd hardening is defence in depth only — btrfs receive needs
CAP_SYS_ADMIN, which can mount() its way out of the namespace those
directives set up. The `dedicated` option turns "this host runs
nothing else" into an assertion the build checks instead of an
assumption the deployer remembers.
2026-07-31 19:03:24 +02:00
atlas
0db83c40a0 feat(#2642): a github.com notification poller alongside the forge one
hive-forge-notify grows a second binary, hive-github-notify. The two
share the notification half of the job — tolerant parse, classification,
formatting, dedupe, todo delivery — and nothing else: each binary owns
its host's protocol outright.

Two binaries rather than one multi-source daemon, and rather than a
cargo feature. A feature would unify across the workspace and cost every
crate its build cache. Two binaries keep the decision in nix: forge.nix
installs the forge unit, github.nix installs the github one under
hyperhive.github.enable, so a hive built without that module has no
github poller in its closure at all — GitHub access is separable (a
tier, a policy boundary), not merely switched off. Both binaries ship
from the existing derivation, so packages.nix is untouched.

The split is real at the code level too, not just at the unit level.
source.rs is a trait; the impls live in the binaries that use them, so
neither binary links the other's protocol code and the library names no
host at all. The forge-only assigned-issue rollup moves into the forge
binary for the same reason: it asks the forge what is assigned to this
agent, which is not a notification-protocol concern.

At runtime the github unit needs a PAT at <state>/github-token, the same
dashboard-provisioned token the gh wrapper and the git credential helper
already use. No PAT: it logs why and exits 0, which is why the unit is
Restart=on-failure and not always.

Forgejo's notifications API is modelled on GitHub's, so one tolerant
parse serves both — the differences (string thread ids, PullRequest vs
Pull) are absorbed by lenient deserializers rather than a second parse
path. Thread ids normalise to String at the parse boundary; they are
only ever opaque keys. Todo keys gain a per-source prefix so the two
hosts cannot collide, and the forge's is deliberately empty to keep
existing forge todo keys stable across the deploy that lands this.

The github loop honours the server's X-Poll-Interval, re-arming only
when the server asks for a slower cadence than ours; the hint is read
before the status check, because it arrives on error and empty pages too
and that is exactly when it matters. Reading the notification stream
needs the notifications scope on the PAT, which a token minted for push
access typically lacks; the failure mode is silence, so docs/github.md
says so explicitly.
2026-07-31 17:23:18 +02:00
damocles
03afbd1316 hivectl: rename hivectl agents to hivectl agent <name> <verb> 2026-07-27 19:07:18 +02:00
damocles
1d3f22805f claude-plugins: generalize state-hygiene into a shared base plugin for all agents 2026-07-27 13:56:29 +02:00
damocles
d207299d0f claude-plugins: fix docs build by adding defaultText to claudeMarketplaces 2026-07-27 13:56:29 +02:00
damocles
2bd1b0a3d5 claude-plugins: rename notes-hygiene to state-hygiene 2026-07-27 13:56:29 +02:00
damocles
bc83fde4ad claude-plugins: ship a hyperhive-authored notes-hygiene skill by default 2026-07-27 13:56:29 +02:00
atlas
4d885df9ad refactor(#2693): pass claudeCodePackage straight into serve.json
`builtins.toJSON` already serialises a derivation as its out path and
`null` as `null`, so the `if … then null else "${…}"` binding was doing
by hand what the serialiser does anyway. Hand the package in whole and
drop the intermediate.

The gc-root property is unchanged and re-measured on the real module:
`environment.etc."hyperhive/serve.json".text` still `hasContext`, so the
host system closure still holds the package alive. Verified both ways —
with the package set the rendered JSON is byte-identical to the
interpolated version, and unset still emits `null` — with all module
assertions passing in each case.

The assertion now checks `builtins.toJSON cfg.claudeCodePackage`, which
is the value that actually has to carry the context, rather than an
intermediate that no longer exists.
2026-07-27 13:56:28 +02:00
atlas
2ad4b43118 refactor(#2693): null, not "", for the unpinned claude-code
mara on PR #2769: "make the default null instead of special casing """.

`claude_code_path` was a `String` whose empty value meant "no host-level
pin". That is a sentinel doing an `Option`'s job — the same shape argus
and mara already rejected on #2755's weights, and the same
empty-field cruft mara called out on #2756.

So it is `Option<String>` end to end:

- host module: `claudeCodePath` evaluates to `null` when
  `claudeCodePackage` is unset, so `serve.json` carries JSON `null`
  rather than `""`.
- `Coordinator` + `HiveEnv`: `Option<String>`, defaulting to `None`.
- `render_flake`/`render_flake_with_lookup`: `Option<&str>`, and the
  emission is an `if let Some(path)` instead of an `is_empty()` guard.
- agent module: `hyperhive.claudeCodePath` is `nullOr str`, default
  `null`.

Behaviour is unchanged in both directions; only the way "unset" is
spelled moves. The `builtins.hasContext` assertion still guards the
pinned case (short-circuited by the null check, so an unpinned hive
never evaluates it).

16/16 `meta::` tests, clippy clean, `nix fmt` no-op, `nix build .#docs`
green.
2026-07-27 13:56:28 +02:00
atlas
b08176f089 feat(#2693): let the operator pin the claude-code every agent runs
Agents run whatever `claude-code` the meta flake's `nixpkgs` resolves
to, and that is normally a release channel. This one package moves fast
enough that stable trails unstable by weeks — 26.05 is on 2.1.187 while
unstable carries 2.1.220 — and an agent cannot fix it for itself: it
only ever sees the single nixpkgs hive-c0re injects, so an `agent.nix`
has no other tree to reach for.

New host option `services.hyperhive.c0re.claudeCodePackage` takes the
package directly and rides the existing `hyperhiveDocs` threading path —
serveConfigJson -> HiveEnv -> render_flake — to reach each agent as
`hyperhive.claudeCodePath`. Null (the default) is today's behaviour.

What travels is the store *path*, as a plain string literal, not a flake
input: containers share the host's `/nix/store`, so the build is already
reachable inside them with its whole closure and has nothing to travel.
An input would be worse than useless — a `path:/nix/store/<pkg>` input
is re-copied as a reference-less `-source`, which strips exactly the
closure the binary needs.

The catch is that a path written into a generated flake is text, so
nothing in the container's closure keeps the binary alive. The host does
that instead, and gets it for free: the package is interpolated into
`/etc/hyperhive/serve.json`, `builtins.toJSON` preserves string context,
so the /etc entry references it and the system closure gc-roots it for
as long as that generation is the one the agents were rendered from. An
assertion pins that property, because losing the context is invisible at
eval and at deploy — it would surface only as every agent failing to
spawn `claude` whenever the next gc ran.

Container side wraps the path in a symlink farm rather than putting it
on PATH directly: `systemd.services.<name>.path` and
`environment.systemPackages` both coerce a store-path *string* through
`lib.toDerivation`, i.e. `builtins.storePath`, which pure evaluation
rejects. Interpolating the path into a builder is just text and
evaluates anywhere. `claude-code` drops out of systemPackages when a
pin is set, so there is exactly one claude in the container.

Refs #2693
2026-07-27 13:56:28 +02:00
atlas
5d3f2af75e refactor(#2754): make the container weights Option, not a 0 sentinel
Encoding "not configured" as weight 0 worked (the writer omitted the
line) but the type lied: 0 is not a legal cgroup v2 weight, and every
reader had to know the sentinel. Use Option<u32> end to end instead —
wire type, priv_client, HiveEnv, drop-in writer — so "unset" is a state
of the type rather than a magic value.

The nix options become nullOr, keeping their default of 80; null now
expresses "leave the setting out of the drop-in entirely" declaratively,
which is the useful shape on a host whose IO scheduler ignores
io.weight anyway.

Backward compat is unchanged: the fields stay #[serde(default)], so a
request from an older hive-c0re deserialises to None and reproduces the
pre-weights drop-in byte for byte. The test that pins that now passes
None instead of 0.
2026-07-27 10:55:29 +02:00
atlas
e407fa93df feat(#2754): hive-wide CPUWeight= / IOWeight= for agent containers
`CPUQuota=`/`MemoryMax=` are hard caps: they throttle an agent even when
the host is idle, so they are the wrong tool for "be polite under
contention". The cgroup v2 relative shares are, and neither was wired.

Adds `services.hyperhive.{agentCpuWeight,agentIoWeight}` (1..=10000,
default 80) threaded through the existing drop-in path: HiveEnv ->
write_dropins -> WriteResourceLimits -> hyperhive-limits.conf, next to
the caps already there. Hive-wide only, as the operator scoped it on the
issue: no per-agent override, no resource-limits.json field, no
dashboard form.

The default of 80 is below the kernel's 100, so agent containers yield
to everything *not* on this drop-in path -- host services and the infra
containers (hive-ci, hive-forge, hive-gateway, hive-matrix). It does not
rank agents against each other; they all carry the same weight.

`WriteResourceLimits` gains two `#[serde(default)]` fields, and the
writer treats weight 0 as "not configured" and omits the line, so an
older hive-c0re talking to a newer hive-priv still produces the exact
pre-weights drop-in. The body is extracted into `limits_dropin_body` so
that is covered by a test rather than asserted by eye.
2026-07-27 10:25:30 +02:00
damocles
bd14cc5c46 feat: derive BUN_JSC_forceRAMSize from effective per-agent MemoryMax= 2026-07-26 21:56:25 +02:00
atlas
246c9471b1 refactor(hive-agent): split the forge notification poller into its own crate
The poller was a `tokio::spawn` inside the `hive-agent` serve loop. It
never needed anything from that loop except a socket path, so being
in-process bought nothing and cost two things: a harness restart took
forge notifications down with it, and the whole forge/HTTP dependency
tree was linked into the serve-loop binary.

It is now `hive-forge-notify`, a per-agent daemon with its own systemd
unit, a sibling of `hive-bash-daemon` and `hive-matrix-daemon`. Same
contract as those two: it reaches the harness only by upserting todos on
the in-agent socket, and nowhere else.

The module moves verbatim (`notify.rs`) — the formatters, the activation
gates, the dedupe map and all 33 tests are unchanged. Only the socket
call sites are rewritten, onto a small local `todo_client` rather than
the harness's. That mirrors what both sibling daemons already do, and
the etiquette differs on purpose: the harness's client carries a 60s
backoff schedule sized to ride out a hive-c0re restart, which its
callers need because they have no retry of their own. This poller's two
call sites both sit inside the 30s poll loop and both treat a failure as
"leave the thread unread, try next tick", so the poll interval already
is the retry; a second backoff would only stack sleeps and delay the
rest of the batch.

The unit is `Restart=on-failure`, not `always`. An agent with no forge
account is a supported configuration and the poller reports it by
logging why and exiting 0 — under `always` that clean exit would be a
restart loop on every forge-less agent.

`forgejo-api`, `url` and `time` drop out of `hive-agent`'s dependencies
with the module.

Also corrects docs that outlived the code they described: the persisted
`forge_cursor` field is long gone (forge's own read-state is the durable
record of what has been delivered), but `docs/persistence.md` and the
`harness_state` module docs still documented it as live.
2026-07-26 21:30:29 +02:00
atlas
14f141625a feat(agent): ship Anthropic's skill-creator plugin by default
Agents had the official marketplace configured out of the box but an
empty plugin list, so nothing was installed from it unless an agent's
own config asked. skill-creator is the one plugin that pays for itself
generically: it teaches an agent to write, refine, and evaluate its own
skills, which is exactly the capability an agent can't bootstrap by
being told about it once in a prompt.

Defaulting the option keeps this consistent with claudeMarketplaces,
which already ships the official marketplace the same way. Documented
the list-option semantics next to both: a per-agent definition replaces
the default rather than extending it, so an agent that sets its own
plugin list has to name skill-creator alongside its entries.
2026-07-26 20:28:51 +02:00
atlas
2af8c2d17d fix(gateway): resync the gateway's resolv.conf when the host's changes
The gateway container's /etc/resolv.conf is a one-shot copy: nixos-container
cps it in from the host in its start script, and nspawn's --resolv-conf=auto
copies (not binds) for a writable host-netns container. systemd-nspawn(1)
states the consequence outright — "no further propagation of configuration is
generally done after the one-time early initialization (this is because the
file is usually updated through copying and renaming)".

dnsmasq has no explicit upstream and follows that file, so a host network
change strands it on a resolver that no longer answers and every non-hive
lookup from every agent hangs. Agents' own resolvers point at the static
bridge IP and never go stale, which is why the symptom presents as "the
gateway needs a kick".

Add a host-side hive-gateway-resolv path unit watching /etc/resolv.conf.
On change it machinectl copy-to's the file into the container and reloads
dnsmasq — ExecReload is kill -HUP, so upstreams are re-read and the cache
flushed without dropping anything; nginx never notices.

- watched from the HOST: a rename on the host doesn't cross the nspawn mount
  namespace, so an in-container path unit can't see it (same reason c0re
  reloads nginx from the host side)
- copy, not a file bind-mount: openresolv renames over the file, so a bind
  would pin the first inode forever — strictly worse than today
- machinectl copy-to writes through the container's own mount namespace, so
  this holds regardless of how the container assembles /etc
- armed Before=network-pre.target so the boot's first DHCP write is caught,
  and re-run on gateway start for changes made while it was down
- a host file with no nameserver line is skipped, not pushed, so a
  mid-rewrite snapshot can't blank hive DNS
- deliberately no fallback server=: dnsmasq queries all known upstreams in
  parallel, so a hardcoded public resolver would take a share of normal
  traffic rather than only covering the gap
2026-07-26 18:01:34 +02:00
damocles
2316287327 remove hive-agent-wake — no shipped consumer 2026-07-25 20:05:32 +02:00
atlas
65a0686297 fix(#2673): set nix fallback in agent + CI containers
Container nix invocations hard-failed whenever the remote builder
muede-pc2 was unreachable, while the identical build on the host
succeeded. Both go through the same host nix-daemon, so the difference
looked impossible.

The cause is that `fallback` is a client-side option: the nix client
transmits it to the daemon per connection (`tryFallback` in setOptions),
so the caller decides whether a failed remote dispatch may degrade to a
local build — even when the build itself runs on the host daemon under
NIX_REMOTE=daemon. Only genuinely daemon-side settings such as
`builders` are inherited from the host. The containers never set
`fallback`, so they took nix's default of false.

Set it in the agent-container base module and in the CI container, and
correct the hive-ci comment that claimed fallback was inherited from
the host daemon along with buildMachines and max-jobs.

Verified in an agent container: `nix fmt` fails outright on the remote
store's connection reset, while the same command with fallback enabled
reports the same connection error and then builds locally and succeeds.
2026-07-25 19:36:31 +02:00