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,313 @@
//! An agent's claude login, run from the swarm UI: the operator's two steps,
//! relayed to the `swarm-claude-login` minter over the queue.
//!
//! - `POST /api/agents/{name}/claude-login` starts `claude auth login` for
//! the agent and answers with the URL to open.
//! - `POST /api/agents/{name}/claude-login/code` hands it the code that page
//! shows; the minter stores the credentials at
//! `swarm/agents/<agent>/claude-credentials` and the agent's harness
//! installs them.
//! - `DELETE /api/agents/{name}/claude-login` stops a login started and not
//! finished.
//!
//! The operator is the `Remote-User` header the gateway sets, as for
//! [`crate::agent_input`]. Nothing here touches the store: the minter holds
//! the one grant that writes the credentials.
use std::future::Future;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use problem_details::ProblemDetails;
use serde::{Deserialize, Serialize};
use swarm_queue_client::claude_login::{REPLY_DEADLINE, Reply, Request, SUBJECT};
use utoipa::ToSchema;
use crate::{AppState, error_problem};
/// The header the gateway names the session's user in.
const OPERATOR_HEADER: &str = "remote-user";
/// Where a step is sent: the queue in production, a fake in the tests.
/// `Err` is a step that never reached the minter or never came back.
pub(crate) trait LoginSink {
fn step(&self, request: Vec<u8>) -> impl Future<Output = Result<Reply, String>> + Send;
}
impl LoginSink for async_nats::Client {
async fn step(&self, request: Vec<u8>) -> Result<Reply, String> {
use async_nats::RequestErrorKind;
let sent = async_nats::Request::new()
.payload(request.into())
.timeout(Some(REPLY_DEADLINE));
match self.send_request(SUBJECT, sent).await {
Ok(msg) => serde_json::from_slice(&msg.payload)
.map_err(|e| format!("the claude login minter's reply did not decode: {e}")),
Err(e) => Err(match e.kind() {
RequestErrorKind::NoResponders => "no claude login minter is listening: \
deploy.claude-login.enable is set on no host, or its service is not \
connected to the swarm queue"
.to_owned(),
RequestErrorKind::TimedOut => format!(
"the claude login minter sent no answer within {}s",
REPLY_DEADLINE.as_secs()
),
_ => format!("sending the step to the claude login minter failed: {e}"),
}),
}
}
}
/// The page the operator opens to log the agent in.
#[derive(Serialize, ToSchema)]
pub struct ClaudeLoginStarted {
pub url: String,
}
/// The code the login page showed. No `Debug`: it is a one-time credential.
#[derive(Deserialize, ToSchema)]
pub struct PostClaudeLoginCodeRequest {
/// Not logged.
code: String,
}
/// Start a claude login for an agent.
#[utoipa::path(
post,
path = "/api/agents/{name}/claude-login",
params(("name" = String, Path, description = "agent to log in")),
responses(
(status = 200, description = "the login is running; open `url` and post the code it shows", body = ClaudeLoginStarted),
(status = 400, description = "the agent name is not an identifier (problem+json)", body = String),
(status = 401, description = "the request carries no `Remote-User` (problem+json)", body = String),
(status = 502, description = "the minter could not start the login (problem+json)", body = String),
(status = 503, description = "no swarm queue here, or no minter listening on it (problem+json)", body = String),
),
tag = "agents"
)]
pub(crate) async fn post_claude_login(
State(state): State<AppState>,
Path(agent): Path<String>,
headers: HeaderMap,
) -> Result<Json<ClaudeLoginStarted>, ProblemDetails> {
let operator = operator(&headers).map_err(problem)?;
let agent = agent_name(&agent).map_err(problem)?;
let sink = queue(&state).map_err(problem)?;
match relay(&sink, &Request::Start { agent, operator }).await? {
Reply::Started { url } => Ok(Json(ClaudeLoginStarted { url })),
other => Err(unexpected(&other)),
}
}
/// Hand an agent's running claude login the code its page showed.
#[utoipa::path(
post,
path = "/api/agents/{name}/claude-login/code",
params(("name" = String, Path, description = "agent being logged in")),
request_body = PostClaudeLoginCodeRequest,
responses(
(status = 204, description = "the credentials are stored; the agent installs them within a minute"),
(status = 400, description = "the agent name is not an identifier, or the code is empty (problem+json)", body = String),
(status = 401, description = "the request carries no `Remote-User` (problem+json)", body = String),
(status = 502, description = "the login failed or its credentials could not be stored (problem+json)", body = String),
(status = 503, description = "no swarm queue here, or no minter listening on it (problem+json)", body = String),
),
tag = "agents"
)]
pub(crate) async fn post_claude_login_code(
State(state): State<AppState>,
Path(agent): Path<String>,
headers: HeaderMap,
Json(req): Json<PostClaudeLoginCodeRequest>,
) -> Result<StatusCode, ProblemDetails> {
let operator = operator(&headers).map_err(problem)?;
let agent = agent_name(&agent).map_err(problem)?;
let code = req.code.trim();
if code.is_empty() {
return Err(error_problem(StatusCode::BAD_REQUEST, "code is empty"));
}
let sink = queue(&state).map_err(problem)?;
let request = Request::Code {
agent: agent.clone(),
code: code.to_owned(),
};
match relay(&sink, &request).await? {
Reply::Stored => {
tracing::info!(%agent, %operator, "claude credentials stored for the agent");
Ok(StatusCode::NO_CONTENT)
}
other => Err(unexpected(&other)),
}
}
/// Stop an agent's running claude login.
#[utoipa::path(
delete,
path = "/api/agents/{name}/claude-login",
params(("name" = String, Path, description = "agent being logged in")),
responses(
(status = 204, description = "no login is running for the agent now"),
(status = 400, description = "the agent name is not an identifier (problem+json)", body = String),
(status = 401, description = "the request carries no `Remote-User` (problem+json)", body = String),
(status = 503, description = "no swarm queue here, or no minter listening on it (problem+json)", body = String),
),
tag = "agents"
)]
pub(crate) async fn delete_claude_login(
State(state): State<AppState>,
Path(agent): Path<String>,
headers: HeaderMap,
) -> Result<StatusCode, ProblemDetails> {
operator(&headers).map_err(problem)?;
let agent = agent_name(&agent).map_err(problem)?;
let sink = queue(&state).map_err(problem)?;
match relay(&sink, &Request::Cancel { agent }).await? {
Reply::Cancelled => Ok(StatusCode::NO_CONTENT),
other => Err(unexpected(&other)),
}
}
/// A refusal decided before the queue is touched.
type Refusal = (StatusCode, String);
fn problem((status, detail): Refusal) -> ProblemDetails {
error_problem(status, &detail)
}
fn operator(headers: &HeaderMap) -> Result<String, Refusal> {
headers
.get(OPERATOR_HEADER)
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
"no Remote-User header: the gateway names the operator, and this request did \
not come through it"
.to_owned(),
)
})
}
fn agent_name(agent: &str) -> Result<String, Refusal> {
Ok(hive_types::Ident::parse(agent)
.map_err(|reason| (StatusCode::BAD_REQUEST, reason.to_owned()))?
.into_string())
}
fn queue(state: &AppState) -> Result<async_nats::Client, Refusal> {
let Some(status) = state.status.as_ref() else {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
"no swarm queue is configured on this host".to_owned(),
));
};
let client = status.queue_client();
swarm_queue_client::ensure_connected(&client).map_err(|e| {
(
StatusCode::SERVICE_UNAVAILABLE,
swarm_queue_client::chain(&e),
)
})?;
Ok(client)
}
/// Send `request` and return the minter's reply, a `Failed` one as a 502.
async fn relay(sink: &impl LoginSink, request: &Request) -> Result<Reply, ProblemDetails> {
let payload = serde_json::to_vec(request).map_err(|e| {
error_problem(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("encoding the step failed: {e}"),
)
})?;
match sink.step(payload).await {
Ok(Reply::Failed { error }) => Err(error_problem(StatusCode::BAD_GATEWAY, &error)),
Ok(reply) => Ok(reply),
Err(e) => Err(error_problem(StatusCode::SERVICE_UNAVAILABLE, &e)),
}
}
fn unexpected(reply: &Reply) -> ProblemDetails {
error_problem(
StatusCode::BAD_GATEWAY,
&format!("the claude login minter answered out of step: {reply:?}"),
)
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use axum::http::StatusCode;
use swarm_queue_client::claude_login::{Reply, Request};
use super::{LoginSink, relay};
/// Answers every step with one fixed outcome and records what it got.
struct FakeSink {
answer: Result<Reply, String>,
got: Mutex<Vec<Vec<u8>>>,
}
impl LoginSink for FakeSink {
async fn step(&self, request: Vec<u8>) -> Result<Reply, String> {
self.got.lock().expect("lock").push(request);
self.answer.clone()
}
}
fn sink(answer: Result<Reply, String>) -> FakeSink {
FakeSink {
answer,
got: Mutex::default(),
}
}
#[tokio::test]
async fn the_code_reaches_the_minter_as_sent() {
let s = sink(Ok(Reply::Stored));
let request = Request::Code {
agent: "atlas".to_owned(),
code: "Abc_9.x-Q".to_owned(),
};
assert!(matches!(relay(&s, &request).await, Ok(Reply::Stored)));
let got: serde_json::Value =
serde_json::from_slice(&s.got.lock().expect("lock")[0]).expect("json");
assert_eq!(got["step"], "code");
assert_eq!(got["agent"], "atlas");
assert_eq!(got["code"], "Abc_9.x-Q");
}
#[tokio::test]
async fn a_failed_step_is_a_bad_gateway_naming_why() {
let s = sink(Ok(Reply::Failed {
error: "OAuth error: invalid code".to_owned(),
}));
let request = Request::Cancel {
agent: "atlas".to_owned(),
};
let e = relay(&s, &request).await.expect_err("a failure");
assert_eq!(e.status, Some(StatusCode::BAD_GATEWAY));
assert!(
e.detail
.as_deref()
.is_some_and(|d| d.contains("invalid code")),
"{:?}",
e.detail
);
}
#[tokio::test]
async fn an_unreached_minter_is_unavailable() {
let s = sink(Err("no claude login minter is listening".to_owned()));
let request = Request::Cancel {
agent: "atlas".to_owned(),
};
let e = relay(&s, &request).await.expect_err("unreached");
assert_eq!(e.status, Some(StatusCode::SERVICE_UNAVAILABLE));
}
}

View file

@ -52,6 +52,7 @@ mod agent_state_stream;
mod agent_status;
mod auth;
mod builder;
mod claude_login;
mod config_pr;
mod forge;
mod forge_account;
@ -3426,6 +3427,11 @@ fn build_app(state: AppState) -> axum::Router {
.routes(routes!(agent_state_stream::stream_agent_state))
.routes(routes!(agent_input::post_agent_input))
.routes(routes!(agent_control::post_agent_cancel))
.routes(routes!(
claude_login::post_claude_login,
claude_login::delete_claude_login
))
.routes(routes!(claude_login::post_claude_login_code))
.routes(routes!(schedules::list_schedules, schedules::add_schedule))
.routes(routes!(
schedules::edit_schedule,