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:
parent
248df20164
commit
d5de1e4943
48 changed files with 2680 additions and 42 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue