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,5 @@
//! `swarmctl agent create` and `swarmctl agent mint-identity` — queue work
//! on the swarm-controller's job graph.
//! `swarmctl agent create`, `swarmctl agent mint-identity` and `swarmctl agent
//! 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
//! print the queued node id and stop: creation's last node only *publishes*
@ -64,6 +64,12 @@ struct MintIdentityResponse {
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`.
///
/// 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(())
}
/// 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.
async fn post_create(socket: &Path, name: &str, hive: &str) -> Result<CreateAgentResponse> {
post(

View file

@ -184,6 +184,15 @@ enum AgentVerb {
/// Queues and returns, the same way `agent create` does — watch the
/// swarm UI's job view for the outcome.
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)]
@ -210,6 +219,19 @@ struct AgentCreateArgs {
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)]
struct AgentMintIdentityArgs {
/// Name of an agent that already exists.
@ -308,6 +330,13 @@ fn main() -> Result<()> {
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
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
// deployment env vars — see the `MarkdownDocs` doc comment above
// 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]
fn parses_authelia_hash_output() {
let out = "Random Password: hunter2\nDigest: $argon2id$v=19$m=65536$abc\n";