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

@ -167,6 +167,11 @@ struct Args {
#[arg(long = "builder-client")]
builder_clients: Vec<String>,
/// Client ids that answer the claude login minter's login requests.
/// Repeatable, empty by default, which denies such a client.
#[arg(long = "claude-login-client")]
claude_login_clients: Vec<String>,
/// Role on the secret store's `cert` auth mount this responder logs in
/// as to read the callout keys, its own OIDC client secret and agent
/// credentials. The store is found
@ -232,7 +237,8 @@ async fn serve(args: &Args) -> anyhow::Result<()> {
)?
.with_agent_token_subjects(args.agent_token_publish_subjects.clone())?
.with_review_classifiers(args.review_classifier_clients.clone())
.with_builders(args.builder_clients.clone());
.with_builders(args.builder_clients.clone())
.with_claude_logins(args.claude_login_clients.clone());
let http = reqwest::Client::new();
let settings = swarm_secret_client::client::Settings::from_env()
.context("reading the secret store's address and this responder's identity")?;

View file

@ -51,6 +51,7 @@ pub struct Policy {
agent_token_subjects: Vec<String>,
review_classifiers: Vec<String>,
builders: Vec<String>,
claude_logins: Vec<String>,
}
/// Placeholder replaced with the hive's own name in `extra_hive_subjects`.
@ -122,6 +123,7 @@ impl Policy {
agent_token_subjects: Vec::new(),
review_classifiers: Vec::new(),
builders: Vec::new(),
claude_logins: Vec::new(),
})
}
@ -157,6 +159,13 @@ impl Policy {
self
}
/// Set the client ids that answer the claude login minter's requests.
#[must_use]
pub fn with_claude_logins(mut self, clients: Vec<String>) -> Self {
self.claude_logins = clients;
self
}
/// The permissions for an agent whose own credential was verified, or
/// `None` when none are configured.
///
@ -229,6 +238,9 @@ impl Policy {
if self.builders.iter().any(|c| c == client_id) {
return Some(Self::builder_permissions(client_id));
}
if self.claude_logins.iter().any(|c| c == client_id) {
return Some(Self::claude_login_permissions(client_id));
}
None
}
@ -263,6 +275,20 @@ impl Policy {
}
}
/// What the claude login minter may do: hear login steps and answer each
/// on its reply subject. Its publish list is its own inbox, for
/// [`Self::review_classifier_permissions`]' reason.
fn claude_login_permissions(client_id: &str) -> Permissions {
Permissions {
publish: vec![Self::inbox(client_id)],
subscribe: Some(vec![
swarm_queue_client::claude_login::SUBJECT.to_owned(),
Self::inbox(client_id),
]),
responses: Some(swarm_queue_client::claude_login::REPLY_DEADLINE),
}
}
/// What a hive may subscribe to: the two events the controller sends it,
/// and its own reply inbox.
///
@ -603,6 +629,8 @@ impl Policy {
swarm_queue_client::review_classifier::SUBJECT.to_owned(),
// A build of an agent config, sent as a request the same way.
swarm_queue_client::builder::SUBJECT.to_owned(),
// An operator's claude login step, sent as a request the same way.
swarm_queue_client::claude_login::SUBJECT.to_owned(),
// The wanted-state buckets — one per hive, all created and written
// by this single client.
//
@ -1402,6 +1430,63 @@ mod tests {
}
}
const CLAUDE_LOGIN: &str = "swarm-claude-login";
#[test]
fn the_claude_login_minter_hears_its_subject_and_inbox_only_and_answers_by_reply() {
use swarm_queue_client::claude_login::{REPLY_DEADLINE, SUBJECT};
use swarm_queue_client::client_inbox_prefix;
let p = policy()
.with_claude_logins(vec![CLAUDE_LOGIN.to_owned()])
.permissions(CLAUDE_LOGIN)
.expect("a configured minter is admitted");
let inbox = client_inbox_prefix(CLAUDE_LOGIN);
assert!(may_subscribe(&p, SUBJECT));
assert!(may_subscribe(&p, &reply(&inbox)));
for refused in [
">".to_owned(),
"$SWARM.>".to_owned(),
swarm_queue_client::builder::SUBJECT.to_owned(),
reply(&client_inbox_prefix("swarm-controller")),
] {
assert!(
!may_subscribe(&p, &refused),
"the minter may subscribe to {refused}"
);
}
assert_eq!(p.publish, vec![format!("{inbox}.>")]);
assert_eq!(p.responses, Some(REPLY_DEADLINE));
// The minter's id is nothing until it is configured.
assert_eq!(policy().permissions(CLAUDE_LOGIN), None);
}
#[test]
fn only_the_reader_may_send_a_login_step() {
use swarm_queue_client::claude_login::SUBJECT;
let p = with_classifier()
.with_builders(vec![BUILDER.to_owned()])
.with_claude_logins(vec![CLAUDE_LOGIN.to_owned()]);
let reader = p.permissions("swarm-controller").expect("the reader");
assert!(reader.publish.iter().any(|g| covers(g, SUBJECT)));
for (who, grant) in [
("a hive", p.permissions("hive-alpha").expect("a hive")),
("the minter", p.permissions(CLAUDE_LOGIN).expect("admitted")),
("the builder", p.permissions(BUILDER).expect("admitted")),
(
"the classifier",
p.permissions(CLASSIFIER).expect("admitted"),
),
] {
assert!(
!grant.publish.iter().any(|g| covers(g, SUBJECT)),
"{who} may send a login step: {:?}",
grant.publish
);
}
}
#[test]
fn the_prefix_alone_names_no_hive() {
// `hive-` would otherwise produce `$KV.hive-status.`, a subject nobody