agent: derive the forge git credential scope instead of hand-writing it

Nothing in the tree rendered a `[credential "<forge>"]` 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.<old-hive>"]
    [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 '<new host>'`. 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.
This commit is contained in:
atlas 2026-08-26 19:39:36 +02:00 committed by mara
commit 399c6f7422

View file

@ -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 "<url>"]` 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 '<new host>'` — 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.<name>.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