iris's diagnosis on #643 (mara's fluffychat-web login attempt): the gateway's
`/matrix/` location used
try_files $uri $uri/ /matrix/index.html;
which silently returned `index.html` (Content-Type: text/html, status 200) for
ANY missing path under `/matrix/`, including static assets like
`native_executor.js`. flutter's bootstrap requested that JS file, got HTML back,
failed to load the JS runtime, and the page rendered blank without any visible
error in the browser console.
(Confirmed root cause for the missing file itself: the upstream `fluffychat-web`
dist in nixpkgs ships `native_executor.dart` but no compiled `native_executor.js`,
even though `main.dart.js` references the latter. That's a separate
fluffychat-web packaging issue — tracked separately; this PR fixes only the
gateway-side masking that hides such failures.)
Replaces the inline `try_files` fallback with a named-location fallback that
distinguishes between route-shaped URIs (no extension) and asset-shaped URIs
(any `.<ext>` suffix):
location /matrix/ {
alias <pkg>/;
try_files $uri $uri/ @matrix_spa_fallback;
}
location @matrix_spa_fallback {
if ($uri ~ "\.[A-Za-z0-9]+$") {
return 404;
}
rewrite ^ /matrix/index.html last;
}
Routes still fall back to `index.html` so SPA client-side routing keeps
working; missing assets now surface a real 404 so flutter (and the operator's
devtools) can see the failure.
Verified the rendered nginx location attr via
`nix eval .#nixosConfigurations.* .... locations."@matrix_spa_fallback".extraConfig`.
The merge of #676 (commit 0951cd1) landed the role-driven harness service
in `harness-base.nix` but the rebase resolution accidentally kept the
legacy `systemd.services.hive-ag3nt` / `hive-m1nd` blocks in
agent-base.nix and manager.nix. Module merging silently accepts the
duplicate definitions because they evaluate to identical attrs — but
the whole point of #671 was to single-source the systemd unit + manager
forge defaults.
Collapses both templates to bare role-setters as originally intended:
{ ... }: {
imports = [ ./harness-base.nix ];
hyperhive.role = "agent"; # or "manager"
}
Verified post-collapse:
- `nixosConfigurations.agent-base.config.systemd.services.hive-ag3nt
.serviceConfig.ExecStart` -> `.../bin/hive-ag3nt serve`
- `nixosConfigurations.manager.config.systemd.services.hive-m1nd
.serviceConfig.ExecStart` -> `.../bin/hive-m1nd serve`
- `agent-base` `.path` is `[ /run/wrappers/bin /run/current-system/sw ... ]`
- `manager` `.environment.HIVE_PORT` is `"8000"`
Follow-up to #671 (#676). No behaviour change — the duplicate
definitions were merging to the same values; this just deletes the
redundant copies so `harness-base.nix` is the true single source.
argus on #676🔴: this PR deletes agent-base.nix + manager.nix and
moves the harness service to harness-base.nix without carrying
forward damocles's #672 fix (which adds `/run/wrappers/bin` to the
service PATH so the setuid sudo wrapper resolves before the bare
nix-store binary).
Pull the #672 fix forward: prepend `/run/wrappers/bin` to the unified
harness service's path list. Same shape as damocles's diff on
agent-base + manager, but applied once in harness-base.nix.
Without this, post-#658 `sudo` inside the container resolves to the
un-setuid nix-store binary and refuses with "must be owned by uid 0
and have the setuid bit set" even when
`hyperhive.user.passwordlessSudo = true` is configured.
Verified via `nix eval`:
- agent-base.systemd.services.hive-ag3nt.path[0] = "/run/wrappers/bin" ✓
- manager.systemd.services.hive-m1nd.path[0] = "/run/wrappers/bin" ✓
#672 (damocles) supersedes when this lands — the two changes are
equivalent and the consolidated harness-base.nix is now the canonical
home for the fix.
mara on #671: "manager should not be as special anymore."
Single `harness-base.nix` now declares the harness systemd unit + the
manager-only forge defaults, driven by a new `hyperhive.role` option
(`"agent"` | `"manager"`, default `"agent"`). The two child templates
collapse to thin role-setters.
Mechanics:
- `hyperhive.role = "agent"` → `systemd.services.hive-ag3nt` running
`hive-ag3nt serve`, default forge notification surface.
- `hyperhive.role = "manager"` → `systemd.services.hive-m1nd` running
`hive-m1nd serve`, forge `keepSubscriptions = false` +
`skipNotifyReasons = [ "subscribed" "participating" ]` (mentions-
only inbox), plus standalone-eval fallbacks `HIVE_PORT = "8000"` +
`HIVE_LABEL = "hm1nd"` (meta.rs overrides via the generated
`applied/hm1nd/flake.nix`).
`agent-base.nix` (62 → 9 lines) and `manager.nix` (79 → 18 lines) are
now thin shims that just set the role and import `harness-base.nix`.
External surface unchanged: `nixosModules.{agent-base, manager}` +
`nixosConfigurations.{agent-base, manager}` still resolve identically.
meta.rs's role selection (`if isManager then hyperhive.nixosConfigurations.manager
else hyperhive.nixosConfigurations.agent-base`) keeps working without
edits.
Verified via `nix eval`:
- agent-base: role="agent", services=["hive-ag3nt"], forge.keepSubscriptions=true
- manager: role="manager", services=["hive-m1nd"], forge.keepSubscriptions=false,
forge.skipNotifyReasons=["subscribed","participating"],
ExecStart=hive-m1nd/bin
Closes#671.
Loose-ends endpoint only carries pending state — a question
disappearing from the list can mean answered, cancelled by asker,
or TTL expired. Previous [answered ✓] glyph implied successful
operator response across all three paths.
Rename .ask-answered-tag → .ask-resolved-tag and use the neutral
[resolved] label. Full resolution detail (who answered with what)
remains visible via the question's history in the side panel.
Addresses argus review note on #668.
When an agent calls mcp__hyperhive__ask with to==operator (the
default), the rich tool-use renderer now mounts an empty
ask-answer-inline-slot inside the expanded ask row and enqueues a
loose-ends refresh. A new reconcileAskBinds() walks waiting slots
on every loose-ends update, matches each against pending
operator-bound questions by question text, and injects the same
inline answer form the side panel uses (buildAnswerForm → POST to
the host dashboard answer-question endpoint). When a question
resolves, the form gets replaced by a struck-through [answered ✓]
tag so the scrollback reflects the closed state.
Lets the operator respond to agent questions inline in the live
terminal without context-switching to the loose-ends side panel or
the dashboard Y3R C4LL tab.
Refreshes loose-ends both on tool_use render (best-effort, may
miss the question before the broker persists it) and on the
matching tool_result (the right moment — MCP has just returned the
assigned id). Slots are pruned on /clear and defensively filtered
for isConnected on each reconcile.
closes#666
argus on #661🟡: "matrix IDs embed server_name irrevocably — anyone
already running with the old default would be broken."
Append a `**Breaking change as of #660**` paragraph to the
`serverName` option description with the exact opt-back-in string,
matching the pattern from #651's openFirewall flip. PR body + commit
already documented the breakage; this surfaces it in the option's
own description so it shows up in `nix flake show` + the auto-
generated options docs right next to the option.
mara on #660: "Matrix domain should default to hive domain if not
set otherwise / redirect matrix clients with .well-known"
Two coupled changes:
1. `services.hyperhive.matrix.serverName` default flipped from
`matrix.${services.hyperhive.domain}` (subdomain) to just
`${services.hyperhive.domain}` (bare hive domain).
This is a "for new deploys only" change — `server_name` is
embedded irrevocably in every user/room ID, so existing
homeservers must set `serverName` explicitly to preserve the
subdomain shape if that's where their identifiers were minted.
Description updated to point at the .well-known piece below.
2. `hive-gateway` nginx now serves matrix-spec `.well-known`
auto-discovery JSON at the canonical location when matrix is
enabled + hive domain set:
GET /.well-known/matrix/client
{"m.homeserver":{"base_url":"http://<domain>:<httpPort>"}}
+ Access-Control-Allow-Origin: * (per matrix spec)
GET /.well-known/matrix/server
{"m.server":"<domain>:<httpPort>"}
tuwunel serves both client + federation on the same `httpPort`
(see hive-matrix.nix), so both records point at the same
endpoint. No-op when matrix isn't enabled or hive domain isn't
set — nothing to advertise.
Combined effect: with `services.hyperhive.domain = "darkest.space"` +
matrix enabled, a matrix client pointed at `darkest.space` resolves
through `.well-known` to the actual `:8008` endpoint, no subdomain
needed. MXIDs become `@atlas:darkest.space` (was: `@atlas:matrix.darkest.space`).
Verified via `nix eval`:
- server_name = "darkest.space" (was "matrix.darkest.space")
- gateway locations include `= /.well-known/matrix/client` + `= /.well-known/matrix/server`
- well-known/matrix/client returns the spec-shaped JSON
Caveat: `m.homeserver.base_url` advertises HTTP (no TLS yet —
follow-up). matrix clients increasingly require HTTPS for new
account creation, so the v0 setup works for local-network testing
but won't satisfy public clients until the gateway TLS story lands.
Closes#660.
Per iris's recommendation on #644 [comment 8043](http://localhost:3000/hyperhive/hyperhive/issues/644#issuecomment-8043):
swap the `chown root:tuwunel + chmod 0640 + pinned GID 10042` shape
(shipped via #649) for systemd's `LoadCredential=` mechanism.
How it works: systemd reads the host-side file at service start,
copies it into a per-service credentials dir
(`/run/credentials/tuwunel.service/registration_token`) owned by
the dynamic user with mode 0400. Service reads from there. All the
namespace mapping happens transparently inside systemd — keeps
`DynamicUser=true` + `PrivateUsers=true` intact.
Net diff from current shape:
- DROP `users.groups.tuwunel.gid = 10042;` from BOTH host AND container
- DROP `chown root:tuwunel "$tokenFile"; chmod 0640 "$tokenFile"`
from activation script; replace with `chmod 0600` (root:root)
- DROP `[ "var" "users" ]` activation dep on `users` (no longer
needs the group to exist before chown)
- ADD `systemd.services.tuwunel.serviceConfig.LoadCredential = [...]`
inside the container config
- CHANGE `registration_token_file` from the bind-mount path to
`/run/credentials/tuwunel.service/registration_token`
- KEEP the bind mount + activation-script token generation (load
credential reads the bind-mounted host file at service start)
Verified via `nix eval`:
- host: no `users.groups.tuwunel` (was: gid = 10042)
- container: tuwunel group exists with `gid = null` (auto-allocated;
no longer pinned to match host since it doesn't need to)
- container: tuwunel.service.serviceConfig.LoadCredential =
`["registration_token:/var/lib/hyperhive/matrix-register-token"]`
- container: services.matrix-tuwunel.settings.global.registration_token_file =
`/run/credentials/tuwunel.service/registration_token`
`/run/credentials/<service>/<id>` is a systemd-stable path
(documented in `man systemd.exec` → LoadCredential); safe to
hardcode.
argus picked option (a) on #653: put the upgrade note in each option's
`description` so it shows up in `nix flake show` + the rendered
options docs, right next to the option itself. cheapest option, no
eval-time noise (a `warnings` block would fire on every new
deployment that wants false — the normal case now).
Appended a `**Breaking change as of #651**` paragraph to each of the
three `openFirewall` descriptions, naming the exact option string the
operator needs to set to restore the old behaviour.
Gateway's note specifically calls out that external reach is the
common case (operator's primary entry point), so the upgrade hint
is most likely needed there.
mara on #651: "Dont default openFirewall to true."
Flip the `openFirewall` default from `true` to `false` for all three
modules that expose host-side ports:
- `services.hyperhive.forge.openFirewall` (httpPort 3000 + sshPort 2222)
- `services.hyperhive.gateway.openFirewall` (port 80)
- `services.hyperhive.matrix.openFirewall` (httpPort 8008)
Rationale: secure-by-default. With shared host netns, the host +
every agent container reach these services via `localhost` regardless
of the firewall — the open only matters for access from outside the
host. Operators who want external reach now flip the bool explicitly:
services.hyperhive.gateway.openFirewall = true;
Each description updated to explain the new default + when to flip
it (operator's browser, external git clients, federation announcement,
etc.). Behind a host-level reverse proxy that handles TLS, leave off.
Verified via `nix eval` on a clean stub config:
- forge openFirewall = false
- gateway openFirewall = false
- matrix openFirewall = false
- networking.firewall.allowedTCPPorts = [] (was: [80 2222 3000 8008])
Note: c0re's direct ports (7000/8000/8100-8999) are gated separately
via #621 on `gateway.enable` — that gate stays; this PR only touches
the per-module `openFirewall` knobs.
Closes#651.
mara on #638: in the dashboard's inputs section, `nixpkgs` appeared
under an `agent-*` path instead of `hyperhive/nixpkgs` where the
operator expects it.
Root cause (post-#632 follows refactor):
- meta's top-level `nixpkgs.follows = "hyperhive/nixpkgs"` is a
`follows` chain, rendered in `flake.lock` as an array — the
`String` extractor in `walk_meta_inputs` correctly skips it (can't
`nix flake update` a follows alias).
- That left the root-level recursion to find `nixpkgs` only through
some other input's subtree.
- Recursion order was the BTreeMap's alphabetical key order, so
`agent-z` (or any agent starting with a letter before `h`) got
walked first and claimed `nixpkgs` at `agent-z/nixpkgs`. Hyperhive's
subsequent walk skipped `nixpkgs` (already visited).
Fix: sort `to_recurse` so hyperhive's subtree is descended first,
matching the same "hyperhive first, then alpha" priority
`read_meta_inputs` already uses for the final output ordering. Now
`nixpkgs` is claimed under `hyperhive/nixpkgs` regardless of which
agents the operator has spawned.
Added regression test covering the exact post-#632 lock shape
(`["hyperhive", "nixpkgs"]` follows array at root, agent-z
alphabetically before hyperhive). Asserts the emitted path is
`hyperhive/nixpkgs` and that `agent-z/nixpkgs` is NOT emitted (the
spanning-tree visited set guarantees one claim per node).
Closes#638.
Footer was still pointing at the old canonical at
`git.berlin.ccc.de/vinzenz/hyperhive` — that's the upstream URL
from before #524 moved the public mirror to `forge.darkest.space`.
README + docs already reference the new URL; this was the only
stale frontend pointer.
mara via triage on #619 (post-merge follow-up):
> "I thought all nixpkgs follow the one hyperhive was deployed
> with?! if not, thats what we should fix."
This is the fix. Flip the rendered meta flake from:
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
hyperhive.url = "...";
hyperhive.inputs.nixpkgs.follows = "nixpkgs";
hyperhive.inputs.nixpkgs-unstable.follows = "nixpkgs-unstable";
to:
hyperhive.url = "...";
nixpkgs.follows = "hyperhive/nixpkgs";
nixpkgs-unstable.follows = "hyperhive/nixpkgs-unstable";
Net effect: hyperhive's `flake.nix` is the single channel-pin
authority. Operators who want to slide the whole swarm onto a
different channel do it at the host level via
`inputs.hyperhive.inputs.nixpkgs.follows = "nixpkgs"`, which makes
hyperhive's nixpkgs = the host's nixpkgs and cascades through every
agent transparently.
This is the same shape I shipped in #619 v1 (then reverted per mara
on comment 7354, then re-affirmed via triage). Going with the
re-affirmed direction.
Closes#317 invariant still satisfied — `nixpkgs` is a single
canonical name in the meta tree, just resolving through hyperhive
instead of being its own root input.
Tests:
- rename `render_flake_declares_canonical_nixpkgs` →
`render_flake_aliases_nixpkgs_to_hyperhive`, asserts the new
follows-form AND the absence of any literal `nixpkgs.url`
- drop `render_flake_collapses_hyperhive_nixpkgs_via_follows` (no
separate meta-level nixpkgs to collapse anymore)
mara on #630: host options page came up empty (template chrome with no
`<h2>` option headers).
Root cause: `pickSubtrees` filtered the host eval against the pre-#615
roots `[ "hyperhive" ]` and `[ "services" "hive-c0re" ]`. #615 moved
the whole host option tree under `services.hyperhive.*`; neither old
root matches anymore, so the filter silently produced an empty tree
and the rendered page degraded to just `<nav> + <main><h1></h1></main>
+ <footer>` chrome.
Fix: pick under `[ "services" "hyperhive" ]`. Agent options stay at
`[ "hyperhive" ]` — per-agent harness options weren't moved by #615.
Before: 100 lines, 0 `<h2>` headers
After: 661 lines, 33 `<h2>` headers covering:
services.hyperhive.{enable,domain,c0re.*,forge.*,matrix.*,gateway.*}
Closes#630.
Per #621 (filed as follow-up to #620 v0): when the gateway is on
(now the default), the c0re dashboard / manager / sub-agent direct
ports should NOT be open in the host firewall — the gateway nginx
is the sole external entry point, proxying to `127.0.0.1:7000` etc.
internally. Leaving them open in the firewall defeats the "single
front door" story.
Wraps the existing `allowedTCPPorts` + `allowedTCPPortRanges` blocks
in `lib.mkIf (!config.services.hyperhive.gateway.enable)`. Operators
who opt out of the gateway still get the direct ports opened so the
legacy `http://<host>:7000/` flow keeps working.
Verified via `nix eval`:
| gateway | allowedTCPPorts (host firewall) | allowedTCPPortRanges |
| --- | --- | --- |
| on | `[80 2222 3000]` (gateway + forge) | `[]` |
| off | `[2222 3000 7000 8000]` (forge + c0re + manager) | `[{from=8100; to=8999}]` (agents) |
Forge ports stay direct in both modes — `hive-forge.nix` opens them
independently and they're not proxied through the gateway (that's a
separate follow-up if wanted).
Closes#621.