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

@ -37,23 +37,17 @@ async fn forgejo_loop(state_dir: String, socket: std::path::PathBuf) {
}
};
let token_path = format!("{state_dir}/forge-token");
// Retry reading the token to handle races where hive-priv provisions
// it after the harness starts, or where a parent-container chown
// briefly makes the file unreadable.
let token = {
let token_paths = hive_forge_notify::forge_token_paths(&state_dir);
// Retry reading the token to handle races where the agent's fetch of
// it from the swarm secret store lands after this unit starts, or where
// a parent-container chown briefly makes the file unreadable.
let mut token = {
let mut attempts = 0u32;
loop {
match tokio::fs::read_to_string(&token_path).await {
Ok(t) => {
let t = t.trim().to_owned();
if !t.is_empty() {
break t;
}
debug!("forge_notify: empty forge token at {token_path}");
}
Err(e) => debug!("forge_notify: cannot read token at {token_path}: {e}"),
if let Some(t) = hive_forge_notify::read_first_token(&token_paths) {
break t;
}
debug!(?token_paths, "forge_notify: no forge token yet");
attempts += 1;
if attempts >= TOKEN_RETRY_MAX {
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;
};
@ -111,6 +105,18 @@ async fn forgejo_loop(state_dir: String, socket: std::path::PathBuf) {
loop {
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() {
own_login = resolve_own_login(&client, &source).await;
if !own_login.is_empty() {

View file

@ -70,3 +70,76 @@ pub fn init_tracing() {
pub fn state_dir() -> String {
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);
}
}