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:
parent
a0a3f9a70c
commit
7dd147adb4
2 changed files with 469 additions and 32 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue