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

@ -1,5 +1,6 @@
//! App-level Forgejo client wrapper. Identity is the per-agent token
//! under `${HYPERHIVE_STATE_DIR}/forge-token`. REST calls go through
//! App-level Forgejo client wrapper. Identity is the per-agent token at
//! `$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
//! [`Client::api`]); a minimal raw `reqwest` client remains for the
//! 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`
/// when `HYPERHIVE_STATE_DIR` isn't set, matching the bash helper.
/// Locate and read the forge token: `HIVE_FORGE_TOKEN_FILE` first (the
/// 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> {
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 raw = std::fs::read_to_string(&path)
.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
/// request body on stdin. Returns `(exit success, stdout, stderr)`.
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("get")
.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);
}
/// 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);
}