Watch
0
0
Fork
You've already forked hyperhive
0

swarm ui: show when an agent's claude login was stored, and delete it

The agent's settings get a claude login row: when the swarm stored the
login, read by the minter from the leaf's metadata, or that none is, with a
delete that removes every version after a confirmation. New minter steps
(status, delete), controller routes (GET claude-login, DELETE
claude-login/stored) and a read and delete grant on the leaf's metadata,
pinned in the module-eval check.
This commit is contained in:
iris 2026-11-09 21:39:17 +01:00 • committed by mara
commit 7dd147adb4
2 changed files with 469 additions and 32 deletions

View file

@ -9,6 +9,9 @@
//! installs them.
//! - `DELETE /api/agents/{name}/claude-login` stops a login started and not
//! finished.
//! - `GET /api/agents/{name}/claude-login` says when the agent's stored login
//! was written, and `DELETE /api/agents/{name}/claude-login/stored` deletes
//! it.
//!
//! The operator is the `Remote-User` header the gateway sets, as for
//! [`crate::agent_input`]. Nothing here touches the store: the minter holds
@ -169,6 +172,79 @@ pub(crate) async fn delete_claude_login(
}
}
/// When the swarm's stored claude login for an agent was written.
#[derive(Serialize, ToSchema)]
pub struct ClaudeLoginStatus {
/// RFC 3339, as the secret store spells it; `null` when nothing is stored.
pub stored_at: Option<String>,
}
/// When an agent's stored claude login was written.
#[utoipa::path(
get,
path = "/api/agents/{name}/claude-login",
params(("name" = String, Path, description = "agent whose login to look up")),
responses(
(status = 200, description = "the time the login was stored, or null", body = ClaudeLoginStatus),
(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 read the store (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 get_claude_login(
State(state): State<AppState>,
Path(agent): Path<String>,
headers: HeaderMap,
) -> Result<Json<ClaudeLoginStatus>, 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::Status { agent }).await? {
Reply::Login { stored_at } => Ok(Json(ClaudeLoginStatus { stored_at })),
other => Err(unexpected(&other)),
}
}
/// Delete an agent's stored claude login, every version of it.
#[utoipa::path(
delete,
path = "/api/agents/{name}/claude-login/stored",
params(("name" = String, Path, description = "agent whose stored login to delete")),
responses(
(status = 204, description = "nothing is stored 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 = 502, description = "the minter could not delete it (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_stored_claude_login(
State(state): State<AppState>,
Path(agent): Path<String>,
headers: HeaderMap,
) -> Result<StatusCode, 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::Delete {
agent: agent.clone(),
},
)
.await?
{
Reply::Deleted => {
tracing::info!(%agent, %operator, "claude credentials deleted for the agent");
Ok(StatusCode::NO_CONTENT)
}
other => Err(unexpected(&other)),
}
}
/// A refusal decided before the queue is touched.
type Refusal = (StatusCode, String);
@ -282,6 +358,31 @@ mod tests {
assert_eq!(got["code"], "Abc_9.x-Q");
}
#[tokio::test]
async fn a_status_and_a_delete_reach_the_minter_by_their_names() {
let s = sink(Ok(Reply::Login { stored_at: None }));
for (request, step) in [
(
Request::Status {
agent: "atlas".to_owned(),
},
"status",
),
(
Request::Delete {
agent: "atlas".to_owned(),
},
"delete",
),
] {
assert!(relay(&s, &request).await.is_ok());
let got = s.got.lock().expect("lock").pop().expect("a step was sent");
let got: serde_json::Value = serde_json::from_slice(&got).expect("json");
assert_eq!(got["step"], step);
assert_eq!(got["agent"], "atlas");
}
}
#[tokio::test]
async fn a_failed_step_is_a_bad_gateway_naming_why() {
let s = sink(Ok(Reply::Failed {

View file

@ -564,7 +564,8 @@ pub(crate) async fn start_forge(
}
/// The routes that write, delete and list an agent's linked accounts and
/// backend environment, kept apart so `build_app` stays under the line limit.
/// backend environment, and those of its claude login, kept apart so
/// `build_app` stays under the line limit.
fn linked_account_routes(router: OpenApiRouter<AppState>) -> OpenApiRouter<AppState> {
router
.routes(routes!(
@ -588,6 +589,13 @@ fn linked_account_routes(router: OpenApiRouter<AppState>) -> OpenApiRouter<AppSt
linked_accounts::delete_backend_env
))
.routes(routes!(linked_accounts::get_linked_accounts))
.routes(routes!(
claude_login::get_claude_login,
claude_login::post_claude_login,
claude_login::delete_claude_login
))
.routes(routes!(claude_login::delete_stored_claude_login))
.routes(routes!(claude_login::post_claude_login_code))
}
/// Every route this daemon serves, wired to its state.
@ -654,11 +662,6 @@ pub(crate) fn build_app(state: AppState) -> axum::Router {
.routes(routes!(agent_input::post_agents_broadcast))
.routes(routes!(agent_control::post_agent_cancel))
.routes(routes!(agent_custom::post_agent_custom))
.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,