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
313
swarm-controller/src/claude_login.rs
Normal file
313
swarm-controller/src/claude_login.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue