From cb1a5cdbb83c1ecba9e01ea2c747edb06fcfd7ce Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 31 May 2026 12:37:33 +0200 Subject: [PATCH 1/2] nix/hive-gateway: per-agent /agent// routing through gateway (#15 v0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per mara on #14 (comment 9081): focused, purely additive to what's there, no TLS / no manager special cases, old `:/` path keeps working. Builds on iris's #731 (agent UI now serves document-relative URLs so it works under any nginx prefix). Mechanics: - New `services.hyperhive.gateway.agents` option (`listOf str`, default `[]`) lists sub-agent names to expose at `/agent//` through the gateway. - For each name, generate one `location /agent//` block that `proxy_pass`es to `http://127.0.0.1:/`, where `` is computed from the same FNV-1a hash hive-c0re uses internally (`lifecycle::agent_web_port`). - Trailing-slash pair on location + proxy_pass strips the `/agent/` prefix on the upstream side — agent server receives `GET /`, `GET /api/state`, `GET /screen/ws`, etc. as if reached directly on its port. - `X-Forwarded-Prefix` set so the harness can build correct absolute URLs for cases where document-relative isn't enough. - `proxyWebsockets = true` + `proxy_buffering off` keeps SSE + WS endpoints working transparently. - Empty `cfg.agents` (default) → no per-agent blocks generated. - Manager not included — already gets `/` via the c0re upstream. FNV-1a hash replicated in nix to match `lifecycle::agent_web_port` line-for-line. Verified against rust output for 8 representative agent names: agent | nix | rust | match iris | 8178 | 8178 | ✓ atlas | 8304 | 8304 | ✓ argus | 8267 | 8267 | ✓ damocles | 8549 | 8549 | ✓ manager | 8000 | 8000 | ✓ (special case) dmatrix | 8266 | 8266 | ✓ triage | 8737 | 8737 | ✓ bitburner | 8658 | 8658 | ✓ Drift hazard documented in the let-block comment: if the rust constants change (MANAGER_PORT, WEB_PORT_BASE, WEB_PORT_RANGE, or the FNV-1a parameters), the nix copy needs a lockstep bump or gateway will proxy to wrong ports. Tracked in the option's description as a follow-up to single-source via `/var/lib/hyperhive/meta/topology.json` lib.importJSON OR runtime nginx-include written by c0re. Char-code lookup table covers `[a-z0-9_-]` — the current `hyperhive.user.name` alphabet. Names with other chars produce an eval-time error rather than a silent wrong hash. Verified: - `nix eval` on the locations attrset for [iris atlas argus damocles] → correct ports (matching rust impl) on each `/agent//` block - empty `cfg.agents` default → no per-agent blocks (`[ "/" ]` only) - full container toplevel builds cleanly with 7 agents + matrix on (`nixos-system-hive-gateway-26.05pre-git`) Sequencing per mara: this is #15 v0 (gateway-side per-agent routing, purely additive). #14 netns isolation follows once this soaks. Out of scope: TLS, manager special-case routing, per-agent unix sockets (mara: "at some point the agent servers will be domain sockets"), CORS workaround removal at `POST /answer-question/{id}`, gateway auth. Closes #15 v0. --- nix/modules/hive-gateway.nix | 147 +++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index d73b5ce1..efc4ae39 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -8,6 +8,67 @@ let cfg = config.services.hyperhive.gateway; hyperhiveDomain = config.services.hyperhive.domain; matrixCfg = config.services.hyperhive.matrix; + + # Per-agent web port, mirroring `hive-c0re::lifecycle::agent_web_port` + # exactly so the gateway and the harness agree on what port to talk + # to without a runtime contract. The manager is fixed at + # `MANAGER_PORT`; every sub-agent is `WEB_PORT_BASE + FNV-1a(name) % + # WEB_PORT_RANGE`. Pure + reproducible from just the name, matches + # the rust constants line-for-line (lifecycle.rs:60-79). + # + # **Drift hazard**: if the rust constants change (MANAGER_PORT, + # WEB_PORT_BASE, WEB_PORT_RANGE, MANAGER_NAME, or the FNV-1a + # parameters), this nix copy must change in lockstep — otherwise + # `/agent//` requests get proxied to the wrong port. There's + # no automated cross-check today (#15 v0 follow-up: have c0re emit + # the port mapping as `/var/lib/hyperhive/agent-ports.json` and + # have this module read it via `lib.importJSON`, single-sourcing + # the table). + agentWebPortLib = + let + # ASCII char → byte code lookup, limited to chars valid in agent + # names (lowercase alpha, digits, dash, underscore). Names with + # other chars are an eval-time error rather than a silent wrong + # hash. Add entries here if hyperhive ever loosens the naming + # constraint. + charCode = { + "0" = 48; "1" = 49; "2" = 50; "3" = 51; "4" = 52; "5" = 53; + "6" = 54; "7" = 55; "8" = 56; "9" = 57; + "-" = 45; "_" = 95; + "a" = 97; "b" = 98; "c" = 99; "d" = 100; "e" = 101; + "f" = 102; "g" = 103; "h" = 104; "i" = 105; "j" = 106; + "k" = 107; "l" = 108; "m" = 109; "n" = 110; "o" = 111; + "p" = 112; "q" = 113; "r" = 114; "s" = 115; "t" = 116; + "u" = 117; "v" = 118; "w" = 119; "x" = 120; "y" = 121; + "z" = 122; + }; + + webPortBase = 8100; + webPortRange = 900; + managerPort = 8000; + managerName = "manager"; + + # nix uses signed 64-bit ints; mask each step to u32 to mirror + # rust's `u32::wrapping_mul`. FNV-1a constants identical to the + # rust version (offset basis 2_166_136_261, prime 16_777_619). + fnv1aU32 = + name: + let + chars = lib.stringToCharacters name; + mask32 = h: lib.bitAnd h 4294967295; + step = + acc: c: + mask32 ( + (lib.bitXor acc ( + charCode.${c} + or (throw "agent name '${name}' contains char '${c}' outside the [a-z0-9_-] alphabet — hyperhive.user.name constraint mismatch?") + )) + * 16777619 + ); + in + lib.foldl' step 2166136261 chars; + in + name: if name == managerName then managerPort else webPortBase + lib.mod (fnv1aU32 name) webPortRange; in { # Single nginx in front of every hyperhive surface (#609 / #15 v0). @@ -117,6 +178,49 @@ in around. Requires `services.hyperhive.domain` to be set. ''; }; + + agents = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ + "iris" + "atlas" + "argus" + "damocles" + ]; + description = '' + Sub-agent names to expose at `http(s):///agent//` + through the gateway (#15 v0). For each name in the list, the + gateway adds a `location /agent//` block that + `proxy_pass`es to `http://127.0.0.1:/`, where `` is + derived from the same `agent_web_port(name)` hash hive-c0re + uses internally (`lifecycle.rs` constants + FNV-1a, replicated + in this module's `let` block). + + **Purely additive** — the old `http://:/` direct + reach still works in parallel; this just gives the operator a + single-origin route. Manager isn't included (no per-agent + prefix needed; manager already gets the `/` route via the + c0re upstream block). + + Empty list (default) leaves the gateway in its pre-#15 shape: + no per-agent routes, only `/` (c0re) + `/matrix/` (fluffychat + when matrix is on) + `.well-known/matrix/*` (matrix + autodiscovery). + + Maintenance: this list is currently operator-managed (the + gateway runs at the host level, the agent list lives in the + meta-flake at runtime, and the host's nix eval doesn't see + the meta-flake's agent dirs). Follow-up tracked at #15: + either auto-derive from + `/var/lib/hyperhive/meta/topology.json` via `lib.importJSON` + in the gateway module (gives a single source of truth at the + cost of an eval-time impurity), or have c0re write an nginx + snippet that the container `include`s + reloads on topology + change (decouples from rebuilds entirely; also handles the + eventual move to per-agent unix sockets). + ''; + }; }; config = lib.mkIf cfg.enable { @@ -248,6 +352,49 @@ in ''; }; } + // + # Per-agent UIs (#15 v0). One `/agent//` + # block per name in `services.hyperhive.gateway.agents`, + # proxying to the agent's harness web server on + # `127.0.0.1:` where `` comes from the + # `agentWebPortLib` FNV-1a hash (matches + # `lifecycle::agent_web_port` line-for-line). + # + # Trailing-slash pair (`/agent//` + `proxy_pass + # http://...:/`) strips the `/agent/` + # prefix on the upstream side, so the agent server + # receives `GET /` for the SPA root, `GET /api/state` + # for the API, `GET /screen/ws` for the websocket, etc. + # The agent's emitted asset URLs are document-relative + # (iris's #731) so they round-trip back through the + # gateway under the same prefix without the harness + # needing prefix-awareness. + # + # `X-Forwarded-Prefix` set so the harness can build + # correct absolute URLs for any case where relative + # isn't enough (server-emitted redirects, OG meta + # tags, etc.). + # + # SSE / websocket support via `proxyWebsockets = true` + # (same as the c0re `/` block below). + # + # Empty `cfg.agents` list → empty attrset → no + # per-agent blocks; old `:/` direct reach + # still works. + builtins.listToAttrs ( + builtins.map (name: { + name = "/agent/${name}/"; + value = { + proxyPass = "http://127.0.0.1:${toString (agentWebPortLib name)}/"; + proxyWebsockets = true; + extraConfig = '' + proxy_set_header X-Forwarded-Prefix /agent/${name}; + proxy_buffering off; + proxy_read_timeout 1d; + ''; + }; + }) cfg.agents + ) // { # Everything else proxies to hive-c0re. Upgrade # headers stay set so SSE (`/dashboard/stream`, From 09c1abe5b408d8a0b599752f4f9284ca5c58c209 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 31 May 2026 12:47:11 +0200 Subject: [PATCH 2/2] nix/hive-gateway: switch to JSON port table per mara on #740 (#15 v0, option B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mara on PR #740 comment 9295: "we decided to go with the json" (issue #15 comment 9270: "nginx container lives in system config, so it cannot be just rebuilt from meta flake. go for the json file the c0re writes"). Drops: - `cfg.agents` listOf str option - Replicated FNV-1a hash + char-code table + manager-port special case - Drift-hazard comment (no more rust↔nix constant sync) Adds: - `cfg.agentPortsFile = "/var/lib/hyperhive/agent-ports.json"` (default, nullable to disable) — path to a JSON map of `{ "": }` written by hive-c0re on every topology change. - `agentPortsTable` reads the file at eval time via `builtins.fromJSON (builtins.readFile path)`, guarded by `builtins.pathExists` so a missing file gracefully defaults to `{}`. - Per-agent locations generated via `lib.mapAttrs'` over the table — one location block per entry; empty table → empty attrset → no per-agent blocks, pre-#15 shape. Rust-side dependency: hive-c0re needs to emit the JSON file on every topology change. Coordinating with damocles via a separate ping — the nix side ships now with safe defaults (missing file = no routes, no behavior change vs main). Verified: - nix eval with `/tmp/test-agent-ports.json` → 4 per-agent blocks at correct ports (8178 iris, 8267 argus, 8304 atlas, 8549 damocles) - nix eval with nonexistent file → only `/` location (graceful default) - full container toplevel builds clean with matrix on Empty file case mirrors the previous empty-list default — purely additive, old `:/` direct reach untouched, no per-agent blocks until c0re writes the JSON. Operator can also `null` the option to disable entirely. --- nix/modules/hive-gateway.nix | 188 +++++++++++++---------------------- 1 file changed, 71 insertions(+), 117 deletions(-) diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index efc4ae39..09b02a13 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -9,66 +9,30 @@ let hyperhiveDomain = config.services.hyperhive.domain; matrixCfg = config.services.hyperhive.matrix; - # Per-agent web port, mirroring `hive-c0re::lifecycle::agent_web_port` - # exactly so the gateway and the harness agree on what port to talk - # to without a runtime contract. The manager is fixed at - # `MANAGER_PORT`; every sub-agent is `WEB_PORT_BASE + FNV-1a(name) % - # WEB_PORT_RANGE`. Pure + reproducible from just the name, matches - # the rust constants line-for-line (lifecycle.rs:60-79). + # Per-agent port table for `/agent//` routing (#15 v0). Single- + # sourced from `cfg.agentPortsFile` (default + # `/var/lib/hyperhive/agent-ports.json`), written by hive-c0re on every + # topology change in shape `{ "": , ... }`. # - # **Drift hazard**: if the rust constants change (MANAGER_PORT, - # WEB_PORT_BASE, WEB_PORT_RANGE, MANAGER_NAME, or the FNV-1a - # parameters), this nix copy must change in lockstep — otherwise - # `/agent//` requests get proxied to the wrong port. There's - # no automated cross-check today (#15 v0 follow-up: have c0re emit - # the port mapping as `/var/lib/hyperhive/agent-ports.json` and - # have this module read it via `lib.importJSON`, single-sourcing - # the table). - agentWebPortLib = - let - # ASCII char → byte code lookup, limited to chars valid in agent - # names (lowercase alpha, digits, dash, underscore). Names with - # other chars are an eval-time error rather than a silent wrong - # hash. Add entries here if hyperhive ever loosens the naming - # constraint. - charCode = { - "0" = 48; "1" = 49; "2" = 50; "3" = 51; "4" = 52; "5" = 53; - "6" = 54; "7" = 55; "8" = 56; "9" = 57; - "-" = 45; "_" = 95; - "a" = 97; "b" = 98; "c" = 99; "d" = 100; "e" = 101; - "f" = 102; "g" = 103; "h" = 104; "i" = 105; "j" = 106; - "k" = 107; "l" = 108; "m" = 109; "n" = 110; "o" = 111; - "p" = 112; "q" = 113; "r" = 114; "s" = 115; "t" = 116; - "u" = 117; "v" = 118; "w" = 119; "x" = 120; "y" = 121; - "z" = 122; - }; - - webPortBase = 8100; - webPortRange = 900; - managerPort = 8000; - managerName = "manager"; - - # nix uses signed 64-bit ints; mask each step to u32 to mirror - # rust's `u32::wrapping_mul`. FNV-1a constants identical to the - # rust version (offset basis 2_166_136_261, prime 16_777_619). - fnv1aU32 = - name: - let - chars = lib.stringToCharacters name; - mask32 = h: lib.bitAnd h 4294967295; - step = - acc: c: - mask32 ( - (lib.bitXor acc ( - charCode.${c} - or (throw "agent name '${name}' contains char '${c}' outside the [a-z0-9_-] alphabet — hyperhive.user.name constraint mismatch?") - )) - * 16777619 - ); - in - lib.foldl' step 2166136261 chars; - in - name: if name == managerName then managerPort else webPortBase + lib.mod (fnv1aU32 name) webPortRange; + # Read at deploy time via `builtins.fromJSON (builtins.readFile ...)` + # — pure eval (the file lives outside the nix store; nix copies the + # content into the store as a fixed-output dep). When the file is + # missing (fresh install before c0re has had a chance to write it), + # default to an empty map → no per-agent routes generated → gateway + # falls back to its pre-#15 shape. The container rebuilds on every + # `hivectl gateway-sync` (operator-initiated) or on the next + # `nixos-rebuild switch`, picking up whatever c0re has written + # since the last build. + # + # mara on #740 (comment 9295) + #15 (comment 9270): the gateway + # nginx container lives in system config (not meta), so it can't + # auto-rebuild from meta-flake events — the JSON file is what + # bridges the host's nix eval to the agent-lifecycle data c0re owns. + agentPortsTable = + if cfg.agentPortsFile == null || !builtins.pathExists cfg.agentPortsFile then + { } + else + builtins.fromJSON (builtins.readFile cfg.agentPortsFile); in { # Single nginx in front of every hyperhive surface (#609 / #15 v0). @@ -179,46 +143,39 @@ in ''; }; - agents = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - example = [ - "iris" - "atlas" - "argus" - "damocles" - ]; + agentPortsFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = "/var/lib/hyperhive/agent-ports.json"; + example = "/var/lib/hyperhive/agent-ports.json"; description = '' - Sub-agent names to expose at `http(s):///agent//` - through the gateway (#15 v0). For each name in the list, the - gateway adds a `location /agent//` block that - `proxy_pass`es to `http://127.0.0.1:/`, where `` is - derived from the same `agent_web_port(name)` hash hive-c0re - uses internally (`lifecycle.rs` constants + FNV-1a, replicated - in this module's `let` block). + Path to a JSON file mapping sub-agent names to their web ports + for `/agent//` routing through the gateway (#15 v0). + Shape: `{ "": , ... }`. Written by hive-c0re on + every topology change (the rust side knows the canonical port + allocation via `lifecycle::agent_web_port`; the gateway just + reads what it's told). - **Purely additive** — the old `http://:/` direct - reach still works in parallel; this just gives the operator a - single-origin route. Manager isn't included (no per-agent - prefix needed; manager already gets the `/` route via the - c0re upstream block). + For each `: ` entry, the gateway adds a + `location /agent//` block that `proxy_pass`es to + `http://127.0.0.1:/`. Empty / missing file → no + per-agent routes generated → gateway falls back to its pre-#15 + shape (just `/` + matrix surfaces). - Empty list (default) leaves the gateway in its pre-#15 shape: - no per-agent routes, only `/` (c0re) + `/matrix/` (fluffychat - when matrix is on) + `.well-known/matrix/*` (matrix - autodiscovery). + **Purely additive**: the old `http://:/` direct + reach keeps working in parallel; this just gives the operator + a single-origin route. Manager isn't included in the map (no + per-agent prefix needed; manager already gets the `/` route + via the c0re upstream block). - Maintenance: this list is currently operator-managed (the - gateway runs at the host level, the agent list lives in the - meta-flake at runtime, and the host's nix eval doesn't see - the meta-flake's agent dirs). Follow-up tracked at #15: - either auto-derive from - `/var/lib/hyperhive/meta/topology.json` via `lib.importJSON` - in the gateway module (gives a single source of truth at the - cost of an eval-time impurity), or have c0re write an nginx - snippet that the container `include`s + reloads on topology - change (decouples from rebuilds entirely; also handles the - eventual move to per-agent unix sockets). + Set to `null` to disable per-agent routing entirely without + creating the file. Set to a custom path if the operator's c0re + writes the table elsewhere. + + **Rebuild trigger**: the gateway container picks up new entries + on the next `nixos-rebuild switch` (or `hivectl gateway-sync` + if that helper lands). c0re writes are not auto-applied to a + running gateway — see the follow-up in #15 for runtime nginx + include + reload + eventual per-agent unix sockets. ''; }; }; @@ -354,11 +311,10 @@ in } // # Per-agent UIs (#15 v0). One `/agent//` - # block per name in `services.hyperhive.gateway.agents`, - # proxying to the agent's harness web server on - # `127.0.0.1:` where `` comes from the - # `agentWebPortLib` FNV-1a hash (matches - # `lifecycle::agent_web_port` line-for-line). + # block per `: ` entry in + # `agentPortsTable` (loaded from `cfg.agentPortsFile` + # — `/var/lib/hyperhive/agent-ports.json` by default, + # written by hive-c0re on every topology change). # # Trailing-slash pair (`/agent//` + `proxy_pass # http://...:/`) strips the `/agent/` @@ -378,23 +334,21 @@ in # SSE / websocket support via `proxyWebsockets = true` # (same as the c0re `/` block below). # - # Empty `cfg.agents` list → empty attrset → no - # per-agent blocks; old `:/` direct reach - # still works. - builtins.listToAttrs ( - builtins.map (name: { - name = "/agent/${name}/"; - value = { - proxyPass = "http://127.0.0.1:${toString (agentWebPortLib name)}/"; - proxyWebsockets = true; - extraConfig = '' - proxy_set_header X-Forwarded-Prefix /agent/${name}; - proxy_buffering off; - proxy_read_timeout 1d; - ''; - }; - }) cfg.agents - ) + # Empty / missing `cfg.agentPortsFile` → empty table + # → no per-agent blocks; old `:/` direct + # reach still works. + lib.mapAttrs' (name: port: { + name = "/agent/${name}/"; + value = { + proxyPass = "http://127.0.0.1:${toString port}/"; + proxyWebsockets = true; + extraConfig = '' + proxy_set_header X-Forwarded-Prefix /agent/${name}; + proxy_buffering off; + proxy_read_timeout 1d; + ''; + }; + }) agentPortsTable // { # Everything else proxies to hive-c0re. Upgrade # headers stay set so SSE (`/dashboard/stream`,