hive-forge-notify grows a second binary, hive-github-notify. The two share the notification half of the job — tolerant parse, classification, formatting, dedupe, todo delivery — and nothing else: each binary owns its host's protocol outright. Two binaries rather than one multi-source daemon, and rather than a cargo feature. A feature would unify across the workspace and cost every crate its build cache. Two binaries keep the decision in nix: forge.nix installs the forge unit, github.nix installs the github one under hyperhive.github.enable, so a hive built without that module has no github poller in its closure at all — GitHub access is separable (a tier, a policy boundary), not merely switched off. Both binaries ship from the existing derivation, so packages.nix is untouched. The split is real at the code level too, not just at the unit level. source.rs is a trait; the impls live in the binaries that use them, so neither binary links the other's protocol code and the library names no host at all. The forge-only assigned-issue rollup moves into the forge binary for the same reason: it asks the forge what is assigned to this agent, which is not a notification-protocol concern. At runtime the github unit needs a PAT at <state>/github-token, the same dashboard-provisioned token the gh wrapper and the git credential helper already use. No PAT: it logs why and exits 0, which is why the unit is Restart=on-failure and not always. Forgejo's notifications API is modelled on GitHub's, so one tolerant parse serves both — the differences (string thread ids, PullRequest vs Pull) are absorbed by lenient deserializers rather than a second parse path. Thread ids normalise to String at the parse boundary; they are only ever opaque keys. Todo keys gain a per-source prefix so the two hosts cannot collide, and the forge's is deliberately empty to keep existing forge todo keys stable across the deploy that lands this. The github loop honours the server's X-Poll-Interval, re-arming only when the server asks for a slower cadence than ours; the hint is read before the status check, because it arrives on error and empty pages too and that is exactly when it matters. Reading the notification stream needs the notifications scope on the PAT, which a token minted for push access typically lacks; the failure mode is silence, so docs/github.md says so explicitly.
130 lines
5.8 KiB
Nix
130 lines
5.8 KiB
Nix
# GitHub integration (hyperhive.github.enable): a `gh` wrapper + a git
|
|
# credential helper, both reading the PAT from the agent's
|
|
# `github-token` state file at invocation, so a dashboard-pasted token
|
|
# takes effect with no rebuild. The token PATH is baked in at build
|
|
# time (nix knows `userName`) — NOT read from `$HIVE_GITHUB_TOKEN_FILE`,
|
|
# because claude's Bash tool runs `bash -c` in a minimal env that
|
|
# doesn't source `/etc/set-environment`, so the env var isn't present
|
|
# where `gh`/`git` actually run. The token value never enters the nix
|
|
# store (only its path). github.com only; git auths as `x-access-token`
|
|
# + PAT.
|
|
{
|
|
pkgs,
|
|
lib,
|
|
config,
|
|
...
|
|
}:
|
|
let
|
|
userName = config.hyperhive.user.name;
|
|
ghWrapper = pkgs.writeShellScriptBin "gh" ''
|
|
if [ -r "/agents/${userName}/state/github-token" ]; then
|
|
GH_TOKEN="$(cat "/agents/${userName}/state/github-token")"
|
|
export GH_TOKEN
|
|
fi
|
|
exec ${pkgs.gh}/bin/gh "$@"
|
|
'';
|
|
gitCredHelper = pkgs.writeShellScriptBin "git-credential-hive-github" ''
|
|
# git credential-helper protocol: only the `get` action needs an answer.
|
|
[ "''${1:-}" = "get" ] || exit 0
|
|
if [ -r "/agents/${userName}/state/github-token" ]; then
|
|
# GitHub ignores the username for PAT auth — `x-access-token` is the
|
|
# conventional placeholder; the PAT is the password.
|
|
printf 'username=x-access-token\n'
|
|
printf 'password=%s\n' "$(cat "/agents/${userName}/state/github-token")"
|
|
fi
|
|
'';
|
|
in
|
|
{
|
|
options.hyperhive.github.enable = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = true;
|
|
description = ''
|
|
Install the GitHub integration in this agent: a `gh` CLI wrapper and a
|
|
git credential helper for `https://github.com`, both authenticated from
|
|
an operator-supplied personal access token (PAT). The PAT is written to
|
|
`<state>/github-token` out of band --- the dashboard credentials tab or
|
|
`hivectl github set-token` --- so giving an agent GitHub is a runtime
|
|
paste, no per-agent config or rebuild. The wrappers read the token file
|
|
at invocation, so a freshly-pasted PAT takes effect immediately; until
|
|
one exists, `gh` / `git push` just fail unauthenticated.
|
|
|
|
github.com only. git authenticates as `x-access-token` + the PAT (GitHub
|
|
ignores the username for PAT auth); `gh` derives its identity from the
|
|
token. Keep the PAT minimally scoped: the agent has passwordless sudo, so
|
|
a compromised agent can act within the token's scopes --- scope is the
|
|
real blast-radius limiter.
|
|
|
|
On by default. Host-driven: set `services.hyperhive.github.enable = false`
|
|
to turn the integration off hive-wide (meta.rs propagates the override
|
|
into every agent).
|
|
'';
|
|
};
|
|
|
|
config = {
|
|
# No bare pkgs.gh here — the wrapper *is* `gh` and hardcodes the
|
|
# real binary path, so it can't be shadowed.
|
|
environment.systemPackages = lib.optionals config.hyperhive.github.enable [
|
|
ghWrapper
|
|
gitCredHelper
|
|
];
|
|
|
|
# Wire the GitHub credential helper for `git push` over HTTPS. Host-scoped
|
|
# to `https://github.com`, so it never touches the forge (localhost:3000)
|
|
# or any other remote. The helper reads the PAT from the agent's
|
|
# `github-token` state file at invocation and auths as `x-access-token` +
|
|
# the PAT. System /etc/gitconfig merges under the agent's ~/.gitconfig
|
|
# (safe.directory), so this is additive.
|
|
# Nested-path binding + mkIf (matching the other `environment.etc."…"`
|
|
# entries in the harness modules) — a whole-set `environment.etc = {…}`
|
|
# here would collide with them at the nix level ("attribute already
|
|
# defined").
|
|
environment.etc."gitconfig" = lib.mkIf config.hyperhive.github.enable {
|
|
text = ''
|
|
[credential "https://github.com"]
|
|
helper = hive-github
|
|
username = x-access-token
|
|
'';
|
|
};
|
|
|
|
# GitHub notification poller — the github.com sibling of
|
|
# `hive-forge-notify` (forge.nix). Same delivery path: unread list ->
|
|
# per-thread summary -> todo on the harness's in-agent socket.
|
|
#
|
|
# Its own binary and its own package, so the deployment decides
|
|
# whether an agent gets GitHub notifications at all — the unit is
|
|
# installed or it isn't. That keeps the choice here rather than in a
|
|
# cargo feature, which would unify across the workspace and stop the
|
|
# two builds sharing any cached crate.
|
|
#
|
|
# The separate package is what makes that real: the per-bin
|
|
# extractor copies exactly one binary, so an agent that installs
|
|
# only the Forgejo poller has no github.com poller anywhere in its
|
|
# closure — not merely an unstarted unit.
|
|
systemd.services.hive-github-notify = lib.mkIf config.hyperhive.github.enable {
|
|
description = "github.com notification poller for this agent";
|
|
wantedBy = [ "multi-user.target" ];
|
|
after = [ "network.target" ];
|
|
environment = {
|
|
# Same in-agent todo socket the forge poller and the harness use
|
|
# (agent-service.nix) — one todo stream, two producers.
|
|
HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
|
|
RUST_LOG = "info";
|
|
# HYPERHIVE_STATE_DIR comes from systemd.globalEnvironment; the
|
|
# poller reads the agent's `github-token` from under it.
|
|
};
|
|
serviceConfig = {
|
|
ExecStart = "${config.hyperhive.packages.hive-github-notify}/bin/hive-github-notify";
|
|
SyslogIdentifier = "hive-github-notify";
|
|
# `on-failure`, NOT `always`, for the same reason as the forge
|
|
# poller: this unit ships on every agent, but most agents have no
|
|
# PAT. The poller reports that by logging why and exiting 0, which
|
|
# under `always` would become a restart loop on every PAT-less
|
|
# agent. A crash still restarts.
|
|
Restart = "on-failure";
|
|
RestartSec = 5;
|
|
User = userName;
|
|
Group = userName;
|
|
};
|
|
};
|
|
};
|
|
}
|