Watch
0
0
Fork
You've already forked hyperhive
0

swarm UI: log an agent into claude from its card; the credentials go to bao and the agent picks them up

A new swarm service, `swarm-claude-login`, in its own container on the host
that sets `deploy.claude-login.enable` (defaulted on by `allSwarmServices`
where the host has the minter's store leaf, like the review classifier).

- swarm-ui: an agent card showing "login needed" gets a "log in" button.
  Its dialog starts the login, shows the URL claude printed, and takes the
  pasted code.
- swarm-controller: `POST/DELETE /api/agents/{name}/claude-login` and
  `POST /api/agents/{name}/claude-login/code`, behind the swarm UI's
  authelia-gated `/api/`, relay the steps to the minter as requests on
  `$SWARM.claude-login.request`.
- swarm-claude-login: runs `claude auth login` over piped stdio with HOME
  and CLAUDE_CONFIG_DIR in a per-login scratch dir, scrapes the first
  https URL, writes the code to stdin, and stores the `.credentials.json`
  at `swarm/agents/<agent>/claude-credentials` with a KV v2 write, a new
  version over any earlier one. The scratch dir goes on every exit path; a
  login waits 15 minutes for its code.
- hive-agent: while parked in `wait_for_login`, reads that leaf every 30 s
  under the agent's own cert and installs it only when its KV version is
  newer than the one recorded in `<harness>/claude-credentials-version`.
  An equal or older version is never put back, because claude rotates the
  refresh token and the stored copy is stale after the first refresh.
- swarm-secret-client: `claude_login` paths and `SecretStore::read_versioned`.
- Credentials: a `claude-login` store leaf minted by glue-bao-tls, a
  `swarm-claude-login` policy (create/update on
  `swarm/agents/+/claude-credentials`, read on its own queue client
  secret), an authelia machine client, and a `--claude-login-client`
  grant in the queue's auth-callout responder. The unit runs as a static
  system user.

The hive's own web-UI login is unchanged.

Refs #5268
This commit is contained in:
atlas 2026-10-14 22:26:53 +02:00 • committed by mara
commit d5de1e4943
48 changed files with 2680 additions and 42 deletions

View file

@ -0,0 +1,93 @@
//! The claude login minter's requests: the subject swarm-controller sends an
//! operator's login steps on, and what travels each way.
//!
//! Three crates name these: the controller sends the requests, the minter
//! answers them, and the auth-callout responder grants the one subject to
//! each.
//!
//! A login is two requests for one agent:
//! [`Request::Start`](crate::claude_login::Request::Start) runs
//! `claude auth login` and answers with the URL it printed, and
//! [`Request::Code`](crate::claude_login::Request::Code) hands it the code the
//! operator pasted and answers once the credentials are in the agent's store
//! leaf. The running login lives in the minter's memory between the two, so
//! the swarm runs one minter.
use std::time::Duration;
use serde::{Deserialize, Serialize};
/// The subject every request goes to. The controller is its one publisher
/// and the minter its one subscriber; the minter answers on the request's
/// reply subject.
pub const SUBJECT: &str = "$SWARM.claude-login.request";
/// The longest the minter waits for `claude` to print the login URL, and
/// then for it to finish once the code is in.
pub const STEP_DEADLINE: Duration = Duration::from_mins(1);
/// How long the controller waits for a reply, and the queue lets the minter
/// answer: a step at its deadline, plus the store write.
pub const REPLY_DEADLINE: Duration = Duration::from_secs(90);
/// How long a started login waits for its code before the minter stops it
/// and deletes its scratch directory.
pub const SESSION_DEADLINE: Duration = Duration::from_mins(15);
/// One step of an operator's login of one agent.
///
/// No `Debug` derive: [`Request::Code`] carries the one-time code.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "step", rename_all = "kebab-case")]
pub enum Request {
/// Start a login for `agent`, replacing one already running for it.
Start {
agent: String,
/// The swarm UI user, as the gateway named them.
operator: String,
},
/// Hand the running login for `agent` the code the operator pasted.
Code { agent: String, code: String },
/// Stop the running login for `agent`, if any.
Cancel { agent: String },
}
/// The minter's answer to one [`Request`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "kebab-case")]
pub enum Reply {
/// The login is running; the operator opens `url` and pastes the code it
/// shows back.
Started { url: String },
/// The credentials are stored in the agent's leaf, as its newest
/// version.
Stored,
/// The running login, if there was one, is stopped.
Cancelled,
/// The step failed: why, with the last lines `claude` printed where it
/// printed any.
Failed { error: String },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_wire_tags_the_request_by_step_and_the_reply_by_state() {
let json = serde_json::to_value(Request::Code {
agent: "atlas".to_owned(),
code: "c".to_owned(),
})
.expect("encodes");
assert_eq!(json["step"], "code");
assert_eq!(json["agent"], "atlas");
let json = serde_json::to_value(Reply::Stored).expect("encodes");
assert_eq!(json["state"], "stored");
}
#[test]
fn the_reply_deadline_outlasts_a_step() {
assert!(REPLY_DEADLINE > STEP_DEADLINE);
}
}

View file

@ -462,6 +462,10 @@ pub mod stored_secret;
/// The builder's build subject and the request and reply it carries.
pub mod builder;
/// The claude login minter's subject and the requests and replies it
/// carries.
pub mod claude_login;
/// Only the fields this needs; authelia returns several.
#[derive(serde::Deserialize)]
struct TokenResponse {