agents: pull the forge token from bao; drop tea-login

forge-token.nix fetches swarm/agents/<agent>/forge-token under the
agent's own store identity into /run/hive-agent-forge-token/token, and
re-fetches on a timer so a rotation lands. hive-forge, the git
credential helper, hive-forge-notify, forge-avatar-sync and the web UI
read that file first and fall back to <state>/forge-token.

tea-login is deleted: it copied the token into ~/.config/tea, which
docs/swarm/credentials.md forbids for a store secret. hive-forge covers
the same verbs. swarmctl gains agent mint-forge-token.

Refs #3782
This commit is contained in:
atlas 2026-09-24 16:35:40 +02:00 • committed by mara
commit dd32a395f7
16 changed files with 501 additions and 156 deletions

View file

@ -263,7 +263,7 @@ wiring is runtime:
- the `hyperhive-isolated-dns` oneshot (`nix/agent-modules/network.nix`), gated on that - the `hyperhive-isolated-dns` oneshot (`nix/agent-modules/network.nix`), gated on that
marker, rewrites `/etc/resolv.conf` to `nameserver <gateway-ip>` at boot. marker, rewrites `/etc/resolv.conf` to `nameserver <gateway-ip>` at boot.
It's ordered `before` the harness (`hive-ag3nt`), the matrix daemon, and It's ordered `before` the harness (`hive-ag3nt`), the matrix daemon, and
`tea-login` so the resolver is correct before the first DNS lookup. the forge-token fetch so the resolver is correct before the first DNS lookup.
**Why isolation is safe**: hive-c0re's control-plane sockets are unix **Why isolation is safe**: hive-c0re's control-plane sockets are unix
domain sockets bind-mounted into containers, not network listeners — see domain sockets bind-mounted into containers, not network listeners — see

View file

@ -502,13 +502,9 @@ sparingly) or build just the suspect check, for example `nix build
## Best-effort oneshot services ## Best-effort oneshot services
The harness ships a family of one-shot systemd services that The harness ships a family of one-shot systemd services that
configure agent-side surfaces from values hive-c0re writes into configure agent-side surfaces from values delivered to the agent at
the state dir at provisioning time: provisioning time:
- `tea-login` — writes `~/.config/tea/config.yml` from the
`forge-token` written by `hive-c0re::forge::ensure_user_for`,
so `tea repos create` / `tea pulls create` work without
interactive prompts.
- `forge-avatar-sync` — uploads `services.hyperhive.agent.icon` SVG to the - `forge-avatar-sync` — uploads `services.hyperhive.agent.icon` SVG to the
agent's Forgejo profile, so the icon shows up on commits / PRs / agent's Forgejo profile, so the icon shows up on commits / PRs /
issue comments. issue comments.
@ -538,13 +534,10 @@ Shape contract — every one of these:
by the `.path` watchers that re-fire on token appearance (see by the `.path` watchers that re-fire on token appearance (see
`docs/agent-lifecycle/persistence.md::Matrix per-agent daemon`). `docs/agent-lifecycle/persistence.md::Matrix per-agent daemon`).
The artefact lives under the agent user's home where applicable The service stays root-owned so the bootstrap ordering doesn't need a
(`~/.config/tea/config.yml`) and is chown'd to that user, but the user-existence check before each fire.
service itself stays root-owned so the bootstrap ordering doesn't
need a user-existence check before each fire.
This pattern keeps the rebuild path resilient: any failure inside This pattern keeps the rebuild path resilient: any failure inside
these services degrades the corresponding surface (no tea config, these services degrades the corresponding surface (no avatar) but never blocks the container from coming up. The
no avatar) but never blocks the container from coming up. The
operator notices through `journalctl -u <unit>` rather than a operator notices through `journalctl -u <unit>` rather than a
broken switch-to-configuration. broken switch-to-configuration.

View file

@ -448,7 +448,7 @@ connects to the compositor at `127.0.0.1:<vnc_port>`.
must never block on weston signalling readiness. A misconfigured must never block on weston signalling readiness. A misconfigured
weston degrades to a `Restart=on-failure` loop visible in weston degrades to a `Restart=on-failure` loop visible in
`journalctl`, it doesn't abort the `nixos-container update`. `journalctl`, it doesn't abort the `nixos-container update`.
Same reasoning as the `tea-login` unit in `nix/agent-modules/forge.nix`. Same reasoning as the best-effort oneshots in `docs/process/conventions.md`.
- **`[core] idle-time=0`**: disables weston's 300-second idle - **`[core] idle-time=0`**: disables weston's 300-second idle
timeout. Without it the VNC desktop fades to black and timeout. Without it the VNC desktop fades to black and
desktop-shell shows its click-to-unlock screen — useless for an desktop-shell shows its click-to-unlock screen — useless for an

View file

@ -137,11 +137,9 @@ services.hyperhive.agent.forge.url = "http://forge.example:3000"; # default: nu
services.hyperhive.agent.matrix.url = "https://matrix.example"; # default: null services.hyperhive.agent.matrix.url = "https://matrix.example"; # default: null
``` ```
**`services.hyperhive.agent.forge.url`** — base URL of the Forgejo instance. Used by **`services.hyperhive.agent.forge.url`** — base URL of the Forgejo instance. It scopes
a one-shot boot unit (`tea-login`) that writes `~/.config/tea/config.yml` the git credential helper to this forge and is where `forge-avatar-sync`
directly from the agent's `forge-token`, so `tea` and `hive-forge` uploads the agent's icon. Override when the agent should connect to a
work without an interactive auth step. The unit is a no-op when
`forge-token` is absent. Override when the agent should connect to a
Forgejo on a different host or port (for example a swarm peer's forge). Forgejo on a different host or port (for example a swarm peer's forge).
Validated: must be an `http://` or `https://` URL, or `null`. Validated: must be an `http://` or `https://` URL, or `null`.
@ -149,7 +147,7 @@ Validated: must be an `http://` or `https://` URL, or `null`.
loopback default would only ever be correct when the forge shares the loopback default would only ever be correct when the forge shares the
agent's network namespace, and inside a container `localhost` is the agent's network namespace, and inside a container `localhost` is the
agent itself, so the default was a value that built fine and then talked agent itself, so the default was a value that built fine and then talked
to the wrong machine. With `null` the `tea-login` and `forge-avatar-sync` to the wrong machine. With `null` the credential helper and `forge-avatar-sync`
units aren't generated at all: an absent integration rather than a units aren't generated at all: an absent integration rather than a
misdirected one. You don't normally set this — hive-c0re renders the misdirected one. You don't normally set this — hive-c0re renders the
host's real forge URL into every agent, and refuses to write a meta host's real forge URL into every agent, and refuses to write a meta

View file

@ -351,7 +351,12 @@ fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
}); });
} }
if crate::paths::state_dir().join("forge-token").is_file() { // Either copy of the forge token: the one fetched from the swarm secret
// store (`HIVE_FORGE_TOKEN_FILE`), or the state-dir file the hive used
// to write.
let fetched_token = std::env::var_os("HIVE_FORGE_TOKEN_FILE")
.is_some_and(|p| std::path::Path::new(&p).is_file());
if fetched_token || crate::paths::state_dir().join("forge-token").is_file() {
links.push(AgentLink { links.push(AgentLink {
url: format!("/{label}"), url: format!("/{label}"),
icon: "🔨".to_owned(), icon: "🔨".to_owned(),

View file

@ -642,7 +642,7 @@ const FORWARDED_VARS: &[&str] = &[
/// Map of forwarded env var -> the agent option carrying the same value. /// Map of forwarded env var -> the agent option carrying the same value.
/// ///
/// Both exist because they're consumed at different times: the option is baked /// Both exist because they're consumed at different times: the option is baked
/// into scripts at build time (tea-login bakes `FORGE_URL` from it), the env /// into scripts at build time (forge-avatar-sync bakes `FORGE_URL` from it), the env
/// var is read at runtime. Setting only one leaves the other on its default, /// var is read at runtime. Setting only one leaves the other on its default,
/// which is how a hive ends up with two disagreeing answers for one value. /// which is how a hive ends up with two disagreeing answers for one value.
/// ///
@ -1242,7 +1242,7 @@ where
# resolve the agent's durable state dir without hard-coding it. # resolve the agent's durable state dir without hard-coding it.
# `environment.variables` only writes /etc/environment (login # `environment.variables` only writes /etc/environment (login
# shells); `systemd.globalEnvironment` is the analogue for # shells); `systemd.globalEnvironment` is the analogue for
# systemd units so tea-login / forge-avatar-sync / # systemd units so forge-avatar-sync /
# hive-matrix-daemon etc. can read `$HYPERHIVE_STATE_DIR` # hive-matrix-daemon etc. can read `$HYPERHIVE_STATE_DIR`
# without each service having to redeclare it. # without each service having to redeclare it.
environment.variables = { environment.variables = {
@ -1259,7 +1259,7 @@ where
// Forwarded vars (HIVE_FORGE_URL etc.) also go into globalEnvironment, // Forwarded vars (HIVE_FORGE_URL etc.) also go into globalEnvironment,
// not just the harness service env below, so EVERY service + shell in // not just the harness service env below, so EVERY service + shell in
// the container inherits them — crucially the bash-task runner (where // the container inherits them — crucially the bash-task runner (where
// `hive-forge` + `git` actually run), plus the matrix daemon, tea-login // `hive-forge` + `git` actually run), plus the matrix daemon, forge-avatar-sync
// and interactive shells. Scoped to the harness service alone they were // and interactive shells. Scoped to the harness service alone they were
// invisible to bash tasks: harmless in shared netns (the localhost // invisible to bash tasks: harmless in shared netns (the localhost
// default works) but broken under isolation, where the in-cluster // default works) but broken under isolation, where the in-cluster
@ -2081,7 +2081,7 @@ mod tests {
fn render_flake_sets_service_url_options_from_forwarded_env() { fn render_flake_sets_service_url_options_from_forwarded_env() {
// The forwarded env vars must ALSO become option assignments, because // The forwarded env vars must ALSO become option assignments, because
// the two are consumed at different times: the option is baked into // the two are consumed at different times: the option is baked into
// scripts at build time (tea-login's FORGE_URL), the env var is read at // scripts at build time (forge-avatar-sync's FORGE_URL), the env var is read at
// runtime. Emitting only the env var leaves the option on its default, // runtime. Emitting only the env var leaves the option on its default,
// which is how a hive ends up with two disagreeing answers for the same // which is how a hive ends up with two disagreeing answers for the same
// URL. // URL.

View file

@ -37,23 +37,17 @@ async fn forgejo_loop(state_dir: String, socket: std::path::PathBuf) {
} }
}; };
let token_path = format!("{state_dir}/forge-token"); let token_paths = hive_forge_notify::forge_token_paths(&state_dir);
// Retry reading the token to handle races where hive-priv provisions // Retry reading the token to handle races where the agent's fetch of
// it after the harness starts, or where a parent-container chown // it from the swarm secret store lands after this unit starts, or where
// briefly makes the file unreadable. // a parent-container chown briefly makes the file unreadable.
let token = { let mut token = {
let mut attempts = 0u32; let mut attempts = 0u32;
loop { loop {
match tokio::fs::read_to_string(&token_path).await { if let Some(t) = hive_forge_notify::read_first_token(&token_paths) {
Ok(t) => {
let t = t.trim().to_owned();
if !t.is_empty() {
break t; break t;
} }
debug!("forge_notify: empty forge token at {token_path}"); debug!(?token_paths, "forge_notify: no forge token yet");
}
Err(e) => debug!("forge_notify: cannot read token at {token_path}: {e}"),
}
attempts += 1; attempts += 1;
if attempts >= TOKEN_RETRY_MAX { if attempts >= TOKEN_RETRY_MAX {
debug!( debug!(
@ -65,7 +59,7 @@ async fn forgejo_loop(state_dir: String, socket: std::path::PathBuf) {
} }
}; };
let Some(source) = ForgejoSource::new(&forge_url, &token) else { let Some(mut source) = ForgejoSource::new(&forge_url, &token) else {
return; return;
}; };
@ -111,6 +105,18 @@ async fn forgejo_loop(state_dir: String, socket: std::path::PathBuf) {
loop { loop {
interval.tick().await; interval.tick().await;
// The swarm rotates the token when it goes stale, and the agent's
// fetch replaces the file; pick the new one up rather than polling
// with a revoked token for the rest of this process's life.
if let Some(t) = hive_forge_notify::read_first_token(&token_paths)
&& t != token
&& let Some(s) = ForgejoSource::new(&forge_url, &t)
{
info!("forge_notify: forge token changed on disk; using the new one");
token = t;
source = s;
own_login.clear();
}
if own_login.is_empty() { if own_login.is_empty() {
own_login = resolve_own_login(&client, &source).await; own_login = resolve_own_login(&client, &source).await;
if !own_login.is_empty() { if !own_login.is_empty() {

View file

@ -70,3 +70,76 @@ pub fn init_tracing() {
pub fn state_dir() -> String { pub fn state_dir() -> String {
std::env::var("HYPERHIVE_STATE_DIR").unwrap_or_default() std::env::var("HYPERHIVE_STATE_DIR").unwrap_or_default()
} }
/// Where this agent's forge token is read from, first match wins:
/// `HIVE_FORGE_TOKEN_FILE` (the copy the agent fetched from the swarm secret
/// store, set by `nix/agent-modules/forge-token.nix`), then
/// `<state_dir>/forge-token` (the file the hive used to write, still the only
/// copy on an agent without a store identity).
#[must_use]
pub fn forge_token_paths(state_dir: &str) -> Vec<std::path::PathBuf> {
let mut paths = Vec::with_capacity(2);
if let Ok(fetched) = std::env::var("HIVE_FORGE_TOKEN_FILE")
&& !fetched.is_empty()
{
paths.push(std::path::PathBuf::from(fetched));
}
paths.push(std::path::Path::new(state_dir).join("forge-token"));
paths
}
/// The first non-empty token among `paths`, trimmed. `None` when none of
/// them holds one.
#[must_use]
pub fn read_first_token(paths: &[std::path::PathBuf]) -> Option<String> {
paths.iter().find_map(|p| {
let t = std::fs::read_to_string(p).ok()?;
let t = t.trim();
(!t.is_empty()).then(|| t.to_owned())
})
}
#[cfg(test)]
mod token_tests {
use super::read_first_token;
fn scratch(tag: &str) -> std::path::PathBuf {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let dir = std::env::temp_dir().join(format!("hive-forge-notify-token-{tag}-{ts}"));
std::fs::create_dir_all(&dir).expect("create scratch dir");
dir
}
#[test]
fn the_fetched_token_wins_over_the_state_file() {
let dir = scratch("wins");
let (fetched, state) = (dir.join("fetched"), dir.join("forge-token"));
std::fs::write(&fetched, "new\n").expect("write");
std::fs::write(&state, "old\n").expect("write");
assert_eq!(read_first_token(&[fetched, state]).as_deref(), Some("new"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_missing_or_empty_fetched_token_falls_back_to_the_state_file() {
let dir = scratch("fallback");
let (fetched, state) = (dir.join("fetched"), dir.join("forge-token"));
std::fs::write(&state, "old\n").expect("write");
assert_eq!(
read_first_token(&[fetched.clone(), state.clone()]).as_deref(),
Some("old")
);
std::fs::write(&fetched, "\n").expect("write");
assert_eq!(read_first_token(&[fetched, state]).as_deref(), Some("old"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn no_token_anywhere_is_none() {
let dir = scratch("none");
assert_eq!(read_first_token(&[dir.join("a"), dir.join("b")]), None);
let _ = std::fs::remove_dir_all(&dir);
}
}

View file

@ -1,5 +1,6 @@
//! App-level Forgejo client wrapper. Identity is the per-agent token //! App-level Forgejo client wrapper. Identity is the per-agent token at
//! under `${HYPERHIVE_STATE_DIR}/forge-token`. REST calls go through //! `$HIVE_FORGE_TOKEN_FILE`, else `${HYPERHIVE_STATE_DIR}/forge-token`
//! (see `read_token`). REST calls go through
//! the typed [`forgejo_api::sync::Forgejo`] client (exposed via //! the typed [`forgejo_api::sync::Forgejo`] client (exposed via
//! [`Client::api`]); a minimal raw `reqwest` client remains for the //! [`Client::api`]); a minimal raw `reqwest` client remains for the
//! few *web-router* routes Forgejo does not serve under `/api/v1/` //! few *web-router* routes Forgejo does not serve under `/api/v1/`
@ -462,9 +463,20 @@ fn state_dir() -> PathBuf {
} }
} }
/// Locate and read the forge token. Falls back to `$PWD/forge-token` /// Locate and read the forge token: `HIVE_FORGE_TOKEN_FILE` first (the
/// when `HYPERHIVE_STATE_DIR` isn't set, matching the bash helper. /// copy the agent fetched from the swarm secret store, set by
/// `nix/agent-modules/forge-token.nix`) when it names a non-empty file,
/// otherwise `<state dir>/forge-token`, the file the hive used to write.
/// The state dir falls back to `$PWD` when `HYPERHIVE_STATE_DIR` isn't
/// set, matching the bash helper.
fn read_token() -> Result<String> { fn read_token() -> Result<String> {
if let Ok(fetched) = std::env::var("HIVE_FORGE_TOKEN_FILE")
&& !fetched.is_empty()
&& let Ok(raw) = std::fs::read_to_string(&fetched)
&& !raw.trim().is_empty()
{
return Ok(raw.trim().to_owned());
}
let path = state_dir().join("forge-token"); let path = state_dir().join("forge-token");
let raw = std::fs::read_to_string(&path) let raw = std::fs::read_to_string(&path)
.with_context(|| format!("hive-forge: no forge-token at {}", path.display()))?; .with_context(|| format!("hive-forge: no forge-token at {}", path.display()))?;

View file

@ -29,7 +29,23 @@ fn scratch_state_dir(tag: &str) -> PathBuf {
/// `state_dir`, feeding `request` as the git credential-protocol /// `state_dir`, feeding `request` as the git credential-protocol
/// request body on stdin. Returns `(exit success, stdout, stderr)`. /// request body on stdin. Returns `(exit success, stdout, stderr)`.
fn run_get(base_url: &str, state_dir: &Path, request: &str) -> (bool, String, String) { fn run_get(base_url: &str, state_dir: &Path, request: &str) -> (bool, String, String) {
let mut child = Command::new(env!("CARGO_BIN_EXE_hive-forge")) run_get_with(base_url, state_dir, None, request)
}
/// [`run_get`], with `HIVE_FORGE_TOKEN_FILE` set to `fetched` when given and
/// removed otherwise, so the caller's own environment never leaks in.
fn run_get_with(
base_url: &str,
state_dir: &Path,
fetched: Option<&Path>,
request: &str,
) -> (bool, String, String) {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_hive-forge"));
match fetched {
Some(p) => cmd.env("HIVE_FORGE_TOKEN_FILE", p),
None => cmd.env_remove("HIVE_FORGE_TOKEN_FILE"),
};
let mut child = cmd
.arg("credential-helper") .arg("credential-helper")
.arg("get") .arg("get")
.env("HIVE_FORGE_URL", base_url) .env("HIVE_FORGE_URL", base_url)
@ -111,3 +127,46 @@ fn credential_helper_get_still_works_with_no_host_line() {
); );
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
/// The token the agent fetched from the swarm secret store wins over the
/// state-dir file the hive used to write, so a rotated token takes effect
/// even while the old file is still on disk.
#[test]
fn the_fetched_token_wins_over_the_state_file() {
let dir = scratch_state_dir("fetched");
let fetched = dir.join("fetched-token");
std::fs::write(&fetched, "fetched-token-value\n").expect("seed fetched token");
let (ok, stdout, stderr) = run_get_with(
"http://forge.internal.example",
&dir,
Some(&fetched),
"protocol=https\nhost=forge.internal.example\n",
);
assert!(ok, "stderr={stderr}");
assert!(
stdout.contains("password=fetched-token-value"),
"stdout={stdout}"
);
assert!(!stdout.contains(TOKEN), "stdout={stdout}");
let _ = std::fs::remove_dir_all(&dir);
}
/// An agent whose token was not minted yet has the variable set and no file
/// behind it; it keeps working off the state-dir file.
#[test]
fn a_fetched_token_that_is_not_there_falls_back_to_the_state_file() {
let dir = scratch_state_dir("fallback");
let missing = dir.join("never-fetched");
let (ok, stdout, stderr) = run_get_with(
"http://forge.internal.example",
&dir,
Some(&missing),
"protocol=https\nhost=forge.internal.example\n",
);
assert!(ok, "stderr={stderr}");
assert!(
stdout.contains(&format!("password={TOKEN}")),
"stdout={stdout}"
);
let _ = std::fs::remove_dir_all(&dir);
}

View file

@ -46,6 +46,7 @@ in
./dashboard-links.nix ./dashboard-links.nix
./docs.nix ./docs.nix
./forge.nix ./forge.nix
./forge-token.nix
./frontend.nix ./frontend.nix
./github.nix ./github.nix
./logs.nix ./logs.nix

View file

@ -0,0 +1,182 @@
# This agent's own forge access token, fetched from the swarm secret store by
# the agent itself.
#
# `swarm-controller` mints the token with the forge's admin API and writes it
# to `swarm/agents/<agent>/forge-token`
# (`swarm_secret_client::forge::agent_token_path`); no hive is in that chain.
# This unit logs in to the store with the certificate ./bao.nix already proves
# it can log in with, and reads its own path. The shape is ./queue-identity.nix's,
# for the same reason: a hive handing the token over would be the hive reading
# a secret on the agent's behalf.
#
# Unlike the queue credential this one rotates: the controller replaces the
# token when the forge's copy stops matching the stored one. So the unit is
# re-run by a timer, and it swaps the file in by rename, only when the value
# changed, so a reader never sees half a token and a watcher on the file
# (forge-avatar-sync.path) fires only on a real change.
#
# Consumers read `services.hyperhive.agent.forge.tokenFile` first and fall back
# to `<state>/forge-token`, the file the hive wrote before this existed.
{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.hyperhive.agent.bao;
# The name `swarm-controller` minted the token under — see ./queue-identity.nix.
agentName = config.services.hyperhive.agent.user.name;
# The same three ids ./bao.nix and ./queue-identity.nix load.
certCredential = "hive-agent-bao-cert";
keyCredential = "hive-agent-bao-key";
serverCaCredential = "hive-agent-bao-server-ca";
unitName = "hive-agent-forge-token";
# The nix half of `swarm_secret_client::forge::agent_token_path` plus
# `path::MOUNT`.
tokenPath = "secret/swarm/agents/${agentName}/forge-token";
runtimeDir = unitName;
tokenFile = "/run/${runtimeDir}/token";
# Beside the token, so the rename that replaces it stays in one directory.
stagingFile = "/run/${runtimeDir}/token.new";
# The store's address is the whole switch, as in ./bao.nix and
# ./queue-identity.nix.
configured = cfg.addr != null;
in
{
options.services.hyperhive.agent.forge.tokenFile = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = tokenFile;
description = ''
Path this agent's own forge token is fetched to. Read-only: it is a
fact about where `${unitName}.service` writes, not a knob.
🩸 A PATH and never a value. The file is `0400` to the agent user.
The file exists only once the swarm has minted a token for this agent
and the agent has a store identity to fetch it with. Until then every
consumer falls back to `$HYPERHIVE_STATE_DIR/forge-token`.
'';
};
config = lib.mkIf configured {
# Every unit and the bash-task runner (where `hive-forge` and `git` run)
# resolve the token through this, the same way they find
# `$HYPERHIVE_STATE_DIR`. A path, never the value.
systemd.globalEnvironment.HIVE_FORGE_TOKEN_FILE = tokenFile;
environment.variables.HIVE_FORGE_TOKEN_FILE = tokenFile;
systemd.services.${unitName} = {
description = "fetch this agent's own forge token from the secret store";
after = [
"network.target"
# Ordering only, for the reason ./queue-identity.nix gives.
"hive-agent-bao-identity.service"
];
before = [ "hive-forge-notify.service" ];
wantedBy = [ "multi-user.target" ];
path = [
pkgs.openbao
pkgs.coreutils
pkgs.diffutils
];
startLimitBurst = 4;
startLimitIntervalSec = 300;
serviceConfig = {
Type = "oneshot";
# Not `RemainAfterExit`, unlike the queue fetch: the timer below has to
# be able to start this unit again, and an active unit cannot be
# started. `RuntimeDirectoryPreserve` is what keeps the directory, and
# the token in it, alive between runs instead.
RemainAfterExit = false;
TimeoutStartSec = 30;
Restart = "on-failure";
RestartSec = 15;
User = agentName;
Group = agentName;
RuntimeDirectory = runtimeDir;
RuntimeDirectoryMode = "0700";
RuntimeDirectoryPreserve = "yes";
UMask = "0377";
LoadCredential = [
certCredential
keyCredential
serverCaCredential
];
};
environment = {
BAO_ADDR = cfg.addr;
BAO_CLIENT_CERT = "%d/${certCredential}";
BAO_CLIENT_KEY = "%d/${keyCredential}";
};
script = ''
set -euo pipefail
# No identity delivered: ./bao.nix's check reports that; saying it
# twice adds nothing.
for id in ${lib.escapeShellArg certCredential} ${lib.escapeShellArg keyCredential}; do
if [ ! -s "$CREDENTIALS_DIRECTORY/$id" ]; then
echo "this agent has no store identity, so it cannot fetch its own forge token." >&2
exit 0
fi
done
if [ -s "$CREDENTIALS_DIRECTORY/${serverCaCredential}" ]; then
export BAO_CACERT="$CREDENTIALS_DIRECTORY/${serverCaCredential}"
fi
err="$(mktemp)"
trap 'rm -f "$err" ${lib.escapeShellArg stagingFile}' EXIT
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
echo "this agent's certificate was refused by the swarm secret store at $BAO_ADDR." >&2
if [ -s "$err" ]; then cat "$err" >&2; fi
exit 1
fi
export BAO_TOKEN
# 🩸 Degrades rather than fails, for the reason ./queue-identity.nix
# gives: the policy stanza that let the login read `bao-mtls` covers
# this path too, so a refusal here is a token not minted yet. The
# file already in place, if any, is kept: a store that is briefly
# unreachable must not take a working token away.
#
# ⚠️ Written by redirect, never echoed: the field is the secret. The
# staging file is removed first so the redirect creates it — a file
# `UMask=0377` left behind is `0400` and could not be reopened for
# writing.
rm -f ${lib.escapeShellArg stagingFile}
if ! bao kv get -field=value ${lib.escapeShellArg tokenPath} > ${lib.escapeShellArg stagingFile} 2>"$err"; then
echo "no forge token at ${tokenPath} yet; consumers keep using the state-dir token if there is one." >&2
if [ -s "$err" ]; then cat "$err" >&2; fi
exit 0
fi
if cmp -s ${lib.escapeShellArg stagingFile} ${lib.escapeShellArg tokenFile}; then
echo "this agent's forge token at ${tokenPath} is unchanged."
exit 0
fi
mv -f ${lib.escapeShellArg stagingFile} ${lib.escapeShellArg tokenFile}
echo "fetched this agent's forge token from ${tokenPath}."
'';
};
# The controller re-checks every agent's token every five minutes; this
# picks up a rotation within about ten more.
systemd.timers.${unitName} = {
description = "re-fetch this agent's forge token from the secret store";
wantedBy = [ "timers.target" ];
timerConfig = {
OnUnitInactiveSec = "10min";
RandomizedDelaySec = "1min";
};
};
};
}

View file

@ -1,6 +1,6 @@
# In-container forge (Forgejo) integration: the `tea` CLI login # In-container forge (Forgejo) integration: the `hive-forge` verb CLI on
# oneshot, the `hive-forge` verb CLI on PATH, and the icon → forge # PATH, the git credential helper, the notification poller, and the icon →
# avatar sync. # forge avatar sync. The token itself is fetched by ./forge-token.nix.
{ {
pkgs, pkgs,
lib, lib,
@ -9,7 +9,18 @@
}: }:
let let
userName = config.services.hyperhive.agent.user.name; userName = config.services.hyperhive.agent.user.name;
homeDir = "/home/${userName}"; # The token the agent fetched from the swarm secret store
# (./forge-token.nix), then the state-dir file the hive used to write,
# which is still the only copy for an agent without a store identity.
# Both are PATHS; each reader below takes the first that holds a token.
fetchedTokenFile = config.services.hyperhive.agent.forge.tokenFile;
stateTokenFile = "/agents/${userName}/state/forge-token";
pickTokenFile = ''
TOKEN_FILE=
for f in ${lib.escapeShellArg fetchedTokenFile} ${lib.escapeShellArg stateTokenFile}; do
if [ -s "$f" ] && [ -r "$f" ]; then TOKEN_FILE="$f"; break; fi
done
'';
# Same 512×512 rasterization of the agent icon the matrix avatar # Same 512×512 rasterization of the agent icon the matrix avatar
# sync uses (./matrix.nix — identical derivation, same store path). # sync uses (./matrix.nix — identical derivation, same store path).
# Only forced when an icon is configured AND a forge is (the avatar-sync # Only forced when an icon is configured AND a forge is (the avatar-sync
@ -20,7 +31,7 @@ let
# git credential helper for the hive forge --- the exact shape # git credential helper for the hive forge --- the exact shape
# `./github.nix` uses for github.com, for the same two reasons: the token # `./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 # is read from its file AT INVOCATION (so a re-issued token
# takes effect with no rebuild), and the token PATH is baked in at build # 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 # 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`. # runs `bash -c` in a minimal env that never sources `/etc/set-environment`.
@ -32,8 +43,8 @@ let
gitCredHelper = pkgs.writeShellScriptBin "git-credential-hive-forge" '' gitCredHelper = pkgs.writeShellScriptBin "git-credential-hive-forge" ''
# git credential-helper protocol: only `get` needs an answer. # git credential-helper protocol: only `get` needs an answer.
[ "''${1:-}" = "get" ] || exit 0 [ "''${1:-}" = "get" ] || exit 0
TOKEN_FILE="/agents/${userName}/state/forge-token" ${pickTokenFile}
[ -r "$TOKEN_FILE" ] || exit 0 [ -n "$TOKEN_FILE" ] || exit 0
printf 'username=%s\n' ${lib.escapeShellArg userName} printf 'username=%s\n' ${lib.escapeShellArg userName}
printf 'password=%s\n' "$(cat "$TOKEN_FILE")" printf 'password=%s\n' "$(cat "$TOKEN_FILE")"
''; '';
@ -44,20 +55,16 @@ in
default = null; default = null;
example = "http://forge.internal:3000"; example = "http://forge.internal:3000";
description = '' description = ''
Base URL of the hyperhive-managed Forgejo. Used at container Base URL of the hyperhive-managed Forgejo. Scopes the git
boot by a oneshot systemd unit that calls credential helper to this forge, and is where the avatar sync
`tea login add --url <this> --token "$(cat $HYPERHIVE_STATE_DIR/forge-token)"` uploads the agent's icon.
(= `/agents/<name>/state/forge-token`) so the agent's claude can
shell out to `tea` without an extra auth dance. No-op when the
forge-token file is missing (i.e. hive-forge isn't running on
the host).
**`null` means "no forge", not "guess one".** There is deliberately **`null` means "no forge", not "guess one".** There is deliberately
no loopback default: the forge may run on a different host from no loopback default: the forge may run on a different host from
the agents, and inside an agent's network namespace `localhost` the agents, and inside an agent's network namespace `localhost`
reaches the agent rather than the forge, so a default would be a reaches the agent rather than the forge, so a default would be a
value that builds fine and then talks to the wrong machine. value that builds fine and then talks to the wrong machine.
When this is `null` the tea-login and avatar-sync units are not When this is `null` the credential helper and avatar-sync units are not
generated at all --- an absent integration, never a misdirected generated at all --- an absent integration, never a misdirected
one. one.
@ -88,10 +95,6 @@ in
]; ];
environment.systemPackages = [ environment.systemPackages = [
# tea: gitea/forgejo CLI client. Configured at boot by the
# tea-login oneshot below if /state/forge-token is present, so
# claude can `tea repos create`, `tea pulls create`, etc.
pkgs.tea
# hive-forge <verb>: CLI wrapping common Forgejo REST API operations # hive-forge <verb>: CLI wrapping common Forgejo REST API operations
# (view, pr, issue, comment, assign, close, labels, branches, etc.). # (view, pr, issue, comment, assign, close, labels, branches, etc.).
# The per-bin split package — narrow closure, no hivectl/wireguard. # The per-bin split package — narrow closure, no hivectl/wireguard.
@ -178,94 +181,22 @@ in
}; };
}; };
# One-shot: tea config.yml from the seeded forge token. Shape # Path-trigger sibling: re-fires forge-avatar-sync when the fetched
# contract (always exit 0, no set -e, skip-silently, re-runnable): # forge token (./forge-token.nix) appears or is replaced. On first
# docs/process/conventions.md::Best-effort oneshot services. # agent deployment the container can boot before the swarm has minted
# Not generated at all when no forge is configured: an absent # the token, so the service fires too early and exits with "no
# integration rather than one pointed at a guessed address. # forge-token found". Without this path unit, nothing would ever
systemd.services.tea-login = lib.mkIf (config.services.hyperhive.agent.forge.url != null) { # re-run it. See docs/agent-lifecycle/persistence.md::forge-avatar-sync.
description = "configure tea CLI from hive-forge token (best-effort)";
wantedBy = [ "multi-user.target" ];
after = [ "local-fs.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
# Pin the journal identity (else it's the `script` store-path wrapper).
SyslogIdentifier = "tea-login";
};
path = [
pkgs.curl
pkgs.jq
pkgs.coreutils
];
environment.HOME_DIR = homeDir;
environment.AGENT_USER = userName;
script = ''
# No `set -e`: best-effort posture (see docs pointer above).
FORGE_URL=${lib.escapeShellArg config.services.hyperhive.agent.forge.url}
# $HYPERHIVE_STATE_DIR is system-wide via the meta flake.
TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token"
if [ ! -f "$TOKEN_FILE" ]; then
echo "tea-login: no forge-token at $TOKEN_FILE; skipping"
exit 0
fi
TOKEN=$(cat "$TOKEN_FILE")
# Resolve the agent username from the forge API.
USER=$(curl -sf --max-time 5 \
-H "Authorization: token $TOKEN" \
"$FORGE_URL/api/v1/user" \
| jq -r '.login // empty' 2>/dev/null || true)
if [ -z "$USER" ]; then
echo "tea-login: could not resolve username from forge API; skipping"
exit 0
fi
# Config under the agent user's home, chown'd to them;
# service stays root-owned (see docs pointer above).
CONFIG="$HOME_DIR/.config/tea/config.yml"
mkdir -p "$(dirname "$CONFIG")" || true
cat > "$CONFIG" << EOF
logins:
- name: forge
url: $FORGE_URL
token: $TOKEN
default: true
ssh_host: ""
ssh_key: ""
insecure: false
ssh_agent: false
user: $USER
preferences:
editor: false
flag_defaults:
remote: ""
EOF
chown -R "$AGENT_USER:$AGENT_USER" "$HOME_DIR/.config" 2>/dev/null || true
echo "tea-login: configured for $FORGE_URL as $USER (config at $CONFIG)"
'';
};
# Path-trigger sibling: re-fires forge-avatar-sync when
# `<state>/forge-token` is written. On first agent deployment the
# container boots before hive-c0re has provisioned the forge-token, so
# the service fires too early and exits with "no forge-token found".
# Without this path unit, RemainAfterExit=true would prevent systemd
# from ever re-running the service. See
# docs/agent-lifecycle/persistence.md::forge-avatar-sync.
# #
# PathChanged=, not PathExists=: a PathExists= condition that already # PathChanged=, not PathExists=: a PathExists= condition that already
# holds re-activates the unit immediately every time the path unit # holds re-activates the unit immediately every time the path unit
# re-arms, and a oneshot re-arms it by deactivating — so it loops until # re-arms, and a oneshot re-arms it by deactivating — so it loops until
# systemd's start limit stops it. PathChanged= does not fire on an # systemd's start limit stops it. PathChanged= does not fire on an
# already-present path, and hive-priv writes this file in place # already-present path, and does fire on the rename the fetch unit
# (write_state_file_nofollow: O_TRUNC, no rename), so close-after-write # swaps a new token in with. The fetch renames only when the value
# still triggers it. Same directive and same reason as # changed, so its timer does not re-upload the avatar every tick.
# swarm-controller.nix's queue-credential watcher.
# #
# ⚠️ This agent's own token, not a glob over `/agents/*/`. Every agent's # ⚠️ This agent's own token, not a glob.
# state dir is visible from inside every container, so a wildcard here
# watches paths this unit has no business reacting to.
# The service reads `$HYPERHIVE_STATE_DIR/forge-token`; this is the same
# file, spelled the way `tea-login` above already spells it.
# #
# ⚠️ Gated on the SAME condition as the service it triggers, not just on # ⚠️ Gated on the SAME condition as the service it triggers, not just on
# the icon: a `.path` unit whose `Unit=` does not exist is a unit pulled # the icon: a `.path` unit whose `Unit=` does not exist is a unit pulled
@ -278,7 +209,7 @@ in
{ {
description = "trigger forge-avatar-sync when forge-token appears"; description = "trigger forge-avatar-sync when forge-token appears";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
pathConfig.PathChanged = "/agents/${userName}/state/forge-token"; pathConfig.PathChanged = fetchedTokenFile;
}; };
# One-shot: services.hyperhive.agent.icon → Forgejo profile avatar. Shape contract: # One-shot: services.hyperhive.agent.icon → Forgejo profile avatar. Shape contract:
@ -295,7 +226,7 @@ in
{ {
description = "sync agent icon to Forgejo user avatar (best-effort)"; description = "sync agent icon to Forgejo user avatar (best-effort)";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
after = [ "tea-login.service" ]; after = [ "hive-agent-forge-token.service" ];
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
RemainAfterExit = false; RemainAfterExit = false;
@ -309,10 +240,8 @@ in
]; ];
script = '' script = ''
FORGE_URL=${lib.escapeShellArg config.services.hyperhive.agent.forge.url} FORGE_URL=${lib.escapeShellArg config.services.hyperhive.agent.forge.url}
# $HYPERHIVE_STATE_DIR is set system-wide by the meta flake ${pickTokenFile}
# (systemd.globalEnvironment) to `/agents/<name>/state`. if [ -z "$TOKEN_FILE" ]; then
TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token"
if [ ! -f "$TOKEN_FILE" ]; then
echo "forge-avatar-sync: no forge-token found; skipping" echo "forge-avatar-sync: no forge-token found; skipping"
exit 0 exit 0
fi fi

View file

@ -45,7 +45,7 @@
# `/etc/hyperhive-bridge-dns` (containing the gateway IP) since # `/etc/hyperhive-bridge-dns` (containing the gateway IP) since
# isolation is always on; the oneshot reads it and rewrites # isolation is always on; the oneshot reads it and rewrites
# resolv.conf on every boot. Ordered before the first DNS consumer # resolv.conf on every boot. Ordered before the first DNS consumer
# (tea-login) and the network targets so name resolution works for # (the forge-token fetch) and the network targets so name resolution works for
# the very first turn. # the very first turn.
systemd.services.hyperhive-isolated-dns = { systemd.services.hyperhive-isolated-dns = {
description = "point resolv.conf at the hive bridge resolver (isolated containers)"; description = "point resolv.conf at the hive bridge resolver (isolated containers)";
@ -61,7 +61,7 @@
# is a harmless no-op when matrix is disabled (the unit is absent). # is a harmless no-op when matrix is disabled (the unit is absent).
before = [ before = [
"network-online.target" "network-online.target"
"tea-login.service" "hive-agent-forge-token.service"
"hive-agent.service" "hive-agent.service"
"hive-matrix-daemon.service" "hive-matrix-daemon.service"
]; ];

View file

@ -1,5 +1,5 @@
//! `swarmctl agent create` and `swarmctl agent mint-identity` — queue work //! `swarmctl agent create`, `swarmctl agent mint-identity` and `swarmctl agent
//! on the swarm-controller's job graph. //! mint-forge-token` — queue work on the swarm-controller's job graph.
//! //!
//! Each POSTs and returns as soon as the work is *inserted*. Both verbs //! Each POSTs and returns as soon as the work is *inserted*. Both verbs
//! print the queued node id and stop: creation's last node only *publishes* //! print the queued node id and stop: creation's last node only *publishes*
@ -64,6 +64,12 @@ struct MintIdentityResponse {
node_id: u64, node_id: u64,
} }
/// Success body of `POST /api/agents/{name}/forge-token`, which takes no body.
#[derive(Deserialize)]
struct MintForgeTokenResponse {
node_id: u64,
}
/// Run `swarmctl agent create`. /// Run `swarmctl agent create`.
/// ///
/// Synchronous on purpose: every other verb in this crate is, and this is /// Synchronous on purpose: every other verb in this crate is, and this is
@ -137,6 +143,33 @@ pub(crate) fn mint_identity(socket: &Path, name: &str, hive: &str) -> Result<()>
Ok(()) Ok(())
} }
/// Run `swarmctl agent mint-forge-token`.
///
/// Synchronous for the same reason [`create`] is, and built on the same
/// round trip. No `--hive`: the token's store path has no hive in it.
pub(crate) fn mint_forge_token(socket: &Path, name: &str) -> Result<()> {
let name = parse_ident(name, "agent name")?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_io()
.build()
.context("starting a tokio runtime for the controller request")?;
let resp: MintForgeTokenResponse = rt.block_on(post(
socket,
&format!("/api/agents/{name}/forge-token"),
&serde_json::json!({}),
"mint-forge-token",
))?;
println!("queued: job node {}", resp.node_id);
println!(
"agent {name:?}'s forge token will be checked once the job graph runs, and minted \
only if it is missing or stale; `swarmctl` does not wait for it. The agent picks a \
new token up on its next fetch"
);
Ok(())
}
/// One `POST /api/agents` round trip over the controller's unix socket. /// One `POST /api/agents` round trip over the controller's unix socket.
async fn post_create(socket: &Path, name: &str, hive: &str) -> Result<CreateAgentResponse> { async fn post_create(socket: &Path, name: &str, hive: &str) -> Result<CreateAgentResponse> {
post( post(

View file

@ -184,6 +184,15 @@ enum AgentVerb {
/// Queues and returns, the same way `agent create` does — watch the /// Queues and returns, the same way `agent create` does — watch the
/// swarm UI's job view for the outcome. /// swarm UI's job view for the outcome.
MintIdentity(AgentMintIdentityArgs), MintIdentity(AgentMintIdentityArgs),
/// Check one agent's forge token, and mint it if it's missing or stale.
///
/// swarm-controller does this for every agent with a store identity at
/// start and every five minutes; this is for when waiting isn't an
/// option. It leaves a current token alone.
///
/// Queues and returns, the same way `agent create` does — watch the
/// swarm UI's job view for the outcome.
MintForgeToken(AgentMintForgeTokenArgs),
} }
#[derive(Args)] #[derive(Args)]
@ -210,6 +219,19 @@ struct AgentCreateArgs {
controller_socket: Option<PathBuf>, controller_socket: Option<PathBuf>,
} }
#[derive(Args)]
struct AgentMintForgeTokenArgs {
/// Name of an agent that already exists.
name: String,
/// swarm-controller's unix socket.
///
/// Supplied by the nix module that installs this binary, from the same
/// `socketPath` option the daemon binds; falls back to
/// `SWARM_CONTROLLER_SOCKET`.
#[arg(long, value_name = "PATH")]
controller_socket: Option<PathBuf>,
}
#[derive(Args)] #[derive(Args)]
struct AgentMintIdentityArgs { struct AgentMintIdentityArgs {
/// Name of an agent that already exists. /// Name of an agent that already exists.
@ -308,6 +330,13 @@ fn main() -> Result<()> {
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?; let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
agent::mint_identity(&socket, &args.name, &args.hive) agent::mint_identity(&socket, &args.name, &args.hive)
} }
// Same socket-resolution reasoning as `Create` above.
Verb::Agent {
command: AgentVerb::MintForgeToken(args),
} => {
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
agent::mint_forge_token(&socket, &args.name)
}
// Resolved lazily, inside the one arm that actually touches the // Resolved lazily, inside the one arm that actually touches the
// deployment env vars — see the `MarkdownDocs` doc comment above // deployment env vars — see the `MarkdownDocs` doc comment above
// for why an unconditional resolve up front would be wrong. // for why an unconditional resolve up front would be wrong.
@ -697,6 +726,31 @@ mod tests {
); );
} }
#[test]
fn the_forge_token_verb_takes_an_agent_and_no_hive() {
let cli = Cli::try_parse_from(["swarmctl", "agent", "mint-forge-token", "scribe"])
.expect("the minimal form parses");
let Verb::Agent {
command: AgentVerb::MintForgeToken(args),
} = cli.command
else {
panic!("expected `agent mint-forge-token`");
};
assert_eq!(args.name, "scribe");
assert!(
Cli::try_parse_from([
"swarmctl",
"agent",
"mint-forge-token",
"scribe",
"--hive",
"a"
])
.is_err(),
"the token has no hive, so the verb must not take one"
);
}
#[test] #[test]
fn parses_authelia_hash_output() { fn parses_authelia_hash_output() {
let out = "Random Password: hunter2\nDigest: $argon2id$v=19$m=65536$abc\n"; let out = "Random Password: hunter2\nDigest: $argon2id$v=19$m=65536$abc\n";