From bcb9e837f7226892fdcd5a1247876daafc8c643d Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 1 Aug 2026 00:36:09 +0200 Subject: [PATCH] fix(#2860): make hyperhive.forge.url nullable instead of guessing a URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- flake.nix | 34 +++------ nix/agent-modules/forge.nix | 135 ++++++++++++++++++++---------------- 2 files changed, 85 insertions(+), 84 deletions(-) diff --git a/flake.nix b/flake.nix index 8eef657c..63818b00 100644 --- a/flake.nix +++ b/flake.nix @@ -149,34 +149,20 @@ nixosConfigurations = let - # Values the agent modules require but that only a real - # deployment can know. Real containers are built from the - # generated meta flake, where hive-c0re renders these per - # agent from the host's `HIVE_FORGE_URL` (see meta.rs's - # `SERVICE_URL_OPTIONS`) — they never evaluate through - # `self.nixosConfigurations`, so nothing here can reach a - # running agent. These two configs exist only to typecheck - # the modules and to pre-build the container closure - # (`system.extraDependencies`, see hive-c0re/default.nix). - # - # Deliberately a `.invalid` host (RFC 2606: guaranteed not to - # resolve) rather than something plausible like a loopback - # port. If this value ever *did* escape into a runtime path, - # it must fail loudly at DNS instead of quietly connecting to - # whatever happens to be listening — which is the entire - # point of removing the `http://localhost:3000` default this - # replaces. - evalOnlyPlaceholders = { - hyperhive.forge.url = "http://forge.invalid"; - }; + # These two configs are what hive-c0re extends per agent + # (meta.rs's `mkAgent` does `.extendModules { … }`, so + # the built base and every container stay on one version). + # Nothing deployment-specific may be set here: it would be + # inherited by every container on the hive and collide with + # the per-agent values hive-c0re renders. The agent modules + # are written so a bare evaluation needs no such values — + # service URLs default to `null`, meaning "not configured", + # and the units that would use them simply aren't generated. mkContainer = module: nixpkgs.lib.nixosSystem { system = "x86_64-linux"; - modules = [ - module - evalOnlyPlaceholders - ]; + modules = [ module ]; }; in { diff --git a/nix/agent-modules/forge.nix b/nix/agent-modules/forge.nix index 0e4eb8e9..a26cf295 100644 --- a/nix/agent-modules/forge.nix +++ b/nix/agent-modules/forge.nix @@ -20,7 +20,8 @@ let in { options.hyperhive.forge.url = lib.mkOption { - type = lib.types.str; + type = lib.types.nullOr lib.types.str; + default = null; example = "http://forge.internal:3000"; description = '' Base URL of the hyperhive-managed Forgejo. Used at container @@ -31,28 +32,38 @@ in forge-token file is missing (i.e. hive-forge isn't running on the host). - **Required, deliberately undefaulted.** hive-c0re renders it into - every agent's config from the host's `HIVE_FORGE_URL`, which - `hive-c0re.nix` sets unconditionally --- the forge is mandatory. - A loopback default would be a guess: the forge may run on a - different host from the agents, and inside an agent's network - namespace `localhost` reaches the agent, not the forge. An - unevaluatable config is better than one that builds and then - talks to the wrong machine. + **`null` means "no forge", not "guess one".** There is deliberately + no loopback default: the forge may run on a different host from + the agents, and inside an agent's network namespace `localhost` + reaches the agent rather than the forge, so a default would be a + value that builds fine and then talks to the wrong machine. + When this is `null` the tea-login and avatar-sync units are not + generated at all --- an absent integration, never a misdirected + one. + + On a real hive it is always set: hive-c0re renders it into every + agent's config from the host's `HIVE_FORGE_URL` (which + `hive-c0re.nix` sets unconditionally) and refuses to render a meta + flake without it, so `null` only survives where the modules are + evaluated outside a hive --- exactly the case that has no forge. ''; }; config = { assertions = [ - # The empty string is the one value the type permits that cannot - # be a URL, and it is what a caller supplies when they have - # nothing --- exactly the case the removed loopback default used - # to paper over. Reject it here so the failure names the option. + # Only a *set* value is constrained. `null` is the legitimate + # "no forge here" state (see the option doc) and is handled by + # not generating the units below, so it must not trip this. + # The empty string, by contrast, is the one non-null value the + # type permits that cannot be a URL --- it is what a caller + # supplies when they have nothing, which is precisely what `null` + # is for, so reject it and name the option. { assertion = - lib.hasPrefix "http://" config.hyperhive.forge.url + config.hyperhive.forge.url == null + || lib.hasPrefix "http://" config.hyperhive.forge.url || lib.hasPrefix "https://" config.hyperhive.forge.url; - message = "hyperhive.forge.url must be an http:// or https:// URL (got: \"${config.hyperhive.forge.url}\")"; + message = "hyperhive.forge.url must be an http:// or https:// URL, or null for no forge (got: \"${toString config.hyperhive.forge.url}\")"; } ]; @@ -105,7 +116,9 @@ in # One-shot: tea config.yml from the seeded forge token. Shape # contract (always exit 0, no set -e, skip-silently, re-runnable): # docs/conventions.md::Best-effort oneshot services. - systemd.services.tea-login = { + # Not generated at all when no forge is configured: an absent + # integration rather than one pointed at a guessed address. + systemd.services.tea-login = lib.mkIf (config.hyperhive.forge.url != null) { description = "configure tea CLI from hive-forge token (best-effort)"; wantedBy = [ "multi-user.target" ]; after = [ "local-fs.target" ]; @@ -188,49 +201,51 @@ in # avatar sync), so the unit only exists when an icon is configured # and needs no librsvg at runtime — Forgejo's Go image library # can't decode SVG, hence PNG. - systemd.services.forge-avatar-sync = lib.mkIf (config.hyperhive.icon != null) { - description = "sync agent icon to Forgejo user avatar (best-effort)"; - wantedBy = [ "multi-user.target" ]; - after = [ "tea-login.service" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = false; - # Pin the journal identity (else it's the `script` store-path wrapper). - SyslogIdentifier = "forge-avatar-sync"; - }; - path = [ - pkgs.curl - pkgs.coreutils - pkgs.jq - ]; - script = '' - FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url} - # $HYPERHIVE_STATE_DIR is set system-wide by the meta flake - # (systemd.globalEnvironment) to `/agents//state`. - TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token" - if [ ! -f "$TOKEN_FILE" ]; then - echo "forge-avatar-sync: no forge-token found; skipping" - exit 0 - fi - TOKEN=$(cat "$TOKEN_FILE") - IMAGE=$(base64 -w 0 < ${iconPng}) - # Forgejo POST /user/avatar expects {"image":""} — just the - # raw base64 string, NOT a data URI (data:image/png;base64,...). - # Use jq to build the payload so the large base64 value is safely quoted. - PAYLOAD=$(jq -n --arg img "$IMAGE" '{image:$img}') - RESP=$(curl -sf --max-time 10 \ - -X POST "$FORGE_URL/api/v1/user/avatar" \ - -H "Authorization: token $TOKEN" \ - -H "Content-Type: application/json" \ - -d "$PAYLOAD" \ - -w "\n%{http_code}" 2>/dev/null || true) - CODE=$(printf '%s' "$RESP" | tail -1) - if [ "$CODE" = "204" ] || [ "$CODE" = "200" ]; then - echo "forge-avatar-sync: avatar uploaded (HTTP $CODE)" - else - echo "forge-avatar-sync: upload returned HTTP $CODE — skipping (non-fatal)" - fi - ''; - }; + systemd.services.forge-avatar-sync = + lib.mkIf (config.hyperhive.icon != null && config.hyperhive.forge.url != null) + { + description = "sync agent icon to Forgejo user avatar (best-effort)"; + wantedBy = [ "multi-user.target" ]; + after = [ "tea-login.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = false; + # Pin the journal identity (else it's the `script` store-path wrapper). + SyslogIdentifier = "forge-avatar-sync"; + }; + path = [ + pkgs.curl + pkgs.coreutils + pkgs.jq + ]; + script = '' + FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url} + # $HYPERHIVE_STATE_DIR is set system-wide by the meta flake + # (systemd.globalEnvironment) to `/agents//state`. + TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token" + if [ ! -f "$TOKEN_FILE" ]; then + echo "forge-avatar-sync: no forge-token found; skipping" + exit 0 + fi + TOKEN=$(cat "$TOKEN_FILE") + IMAGE=$(base64 -w 0 < ${iconPng}) + # Forgejo POST /user/avatar expects {"image":""} — just the + # raw base64 string, NOT a data URI (data:image/png;base64,...). + # Use jq to build the payload so the large base64 value is safely quoted. + PAYLOAD=$(jq -n --arg img "$IMAGE" '{image:$img}') + RESP=$(curl -sf --max-time 10 \ + -X POST "$FORGE_URL/api/v1/user/avatar" \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + -w "\n%{http_code}" 2>/dev/null || true) + CODE=$(printf '%s' "$RESP" | tail -1) + if [ "$CODE" = "204" ] || [ "$CODE" = "200" ]; then + echo "forge-avatar-sync: avatar uploaded (HTTP $CODE)" + else + echo "forge-avatar-sync: upload returned HTTP $CODE — skipping (non-fatal)" + fi + ''; + }; }; }