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:
parent
52c8c0b0de
commit
dd32a395f7
16 changed files with 501 additions and 156 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue