From 399c6f742291f823ad9a9093542bf457cd014867 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 26 Aug 2026 19:39:36 +0200 Subject: [PATCH] agent: derive the forge git credential scope instead of hand-writing it Nothing in the tree rendered a `[credential ""]` entry for an agent, so every agent's `~/.gitconfig` accumulated one by hand, per generation of forge address. Append-only, none ever removed, and after the domain move the live one absent entirely: [credential "http://forge."] [credential "http://localhost:3000"] The absence of a writer is the defect. A value interpolated at eval time follows a rename; a value captured into a mutable home file does not. hive-c0re's own gitconfig already derives its scope from `swarm.forge.domain` and moved correctly for exactly that reason. What made it expensive to diagnose is that it does not present as a credential problem. `git fetch` against a stale remote still succeeds -- the old name redirects and a public read needs no auth -- so the break surfaces only at the first authenticated push, long after the move, as `could not read Username for ''`. That names a host the agent was never configured for, which reads like DNS or TLS. Same class as the CI runner keeping its registered address, one tier down. The shape is `github.nix`'s, unchanged: a small credential helper that reads the token from the agent's state file at invocation, with the token PATH baked in rather than the value, because claude's Bash tool runs in a minimal env that never sources /etc/set-environment. `environment.etc."gitconfig"` is already bound by github.nix; the two merge rather than collide because the option is `lines`. Verified by eval with both modules defining it before this was written -- a silent last-wins there would drop one integration's credentials and look exactly like this bug again. --- nix/agent-modules/forge.nix | 56 ++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/nix/agent-modules/forge.nix b/nix/agent-modules/forge.nix index a26cf295..bea7bff8 100644 --- a/nix/agent-modules/forge.nix +++ b/nix/agent-modules/forge.nix @@ -17,6 +17,26 @@ let iconPng = pkgs.runCommand "hive-agent-icon.png" { nativeBuildInputs = [ pkgs.librsvg ]; } '' rsvg-convert -f png -w 512 -h 512 ${config.hyperhive.icon} -o $out ''; + + # git credential helper for the hive forge --- the exact shape + # `./github.nix` uses for github.com, for the same two reasons: the token + # is read from the agent's state file AT INVOCATION (so a re-issued token + # takes effect with no rebuild), and the token PATH is baked in at build + # time rather than read from the environment, because claude's Bash tool + # runs `bash -c` in a minimal env that never sources `/etc/set-environment`. + # + # The alternative --- a token spliced into `remote.origin.url` --- is worse + # than it looks: any command that prints a remote (`git remote -v`, a push + # failure) writes the secret into `harness/bash-tasks/*.{out,err}`, which is + # bind-mounted rw into the agent and never swept. + gitCredHelper = pkgs.writeShellScriptBin "git-credential-hive-forge" '' + # git credential-helper protocol: only `get` needs an answer. + [ "''${1:-}" = "get" ] || exit 0 + TOKEN_FILE="/agents/${userName}/state/forge-token" + [ -r "$TOKEN_FILE" ] || exit 0 + printf 'username=%s\n' ${lib.escapeShellArg userName} + printf 'password=%s\n' "$(cat "$TOKEN_FILE")" + ''; in { options.hyperhive.forge.url = lib.mkOption { @@ -76,7 +96,41 @@ in # (view, pr, issue, comment, assign, close, labels, branches, etc.). # The per-bin split package — narrow closure, no hivectl/wireguard. config.hyperhive.packages.hive-forge - ]; + ] + ++ lib.optional (config.hyperhive.forge.url != null) gitCredHelper; + + # Wire the forge credential helper for `git push`, scoped to the forge + # this agent is configured for. + # + # ⚠️ THE SCOPE IS DERIVED, AND THAT IS THE ENTIRE POINT. Before this + # existed nothing rendered it, so each agent's `~/.gitconfig` accumulated a + # hand-written `[credential ""]` per generation of forge address — + # append-only, none removed, and after a domain move the live one absent. + # A value interpolated at eval time follows a rename; a value captured into + # a mutable home file does not. + # + # The failure it caused is worth naming because it does not look like a + # credential problem: `git fetch` against a stale remote still SUCCEEDS + # (the old name redirects, and a public read needs no auth), so the break + # surfaces only at the first authenticated push, as + # `could not read Username for ''` — naming a host the agent was + # never configured for, which reads like DNS or TLS. + # + # Trailing slash stripped: git matches a credential section by + # scheme+host+port, and `http://host/` is not that. + # + # Nested-path binding, matching `./github.nix` — and the two MERGE rather + # than collide, because `environment.etc..text` is `lines`. Verified + # by eval with both modules defining it, not assumed: a silent last-wins + # here would drop one integration's credentials and look exactly like this + # bug again. + environment.etc."gitconfig" = lib.mkIf (config.hyperhive.forge.url != null) { + text = '' + [credential "${lib.removeSuffix "/" config.hyperhive.forge.url}"] + helper = git-credential-hive-forge + username = ${userName} + ''; + }; # Forge notification poller — a long-running sibling of # `hive-bash-daemon` / `hive-matrix-daemon`. Polls the agent's unread