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
+ '';
+ };
};
}