From 78cb861412850889424ae7d5ae5cd952a2b90295 Mon Sep 17 00:00:00 2001 From: soryu Date: Mon, 2 Mar 2026 15:18:31 +0000 Subject: feat: move daemon reauth to daemons page, add contract-backed directive steps, rename Mesh to Exec (#84) * feat: soryu-co/soryu - makima: Rename Mesh to Exec in navigation * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Add contract-backed steps to directive flow * WIP: heartbeat checkpoint --- makima/src/server/handlers/mesh.rs | 284 +++++++++++++++++++++++++++++- makima/src/server/handlers/mesh_daemon.rs | 31 ++++ 2 files changed, 314 insertions(+), 1 deletion(-) (limited to 'makima/src/server/handlers') diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs index c840676..0e72bdf 100644 --- a/makima/src/server/handlers/mesh.rs +++ b/makima/src/server/handlers/mesh.rs @@ -20,7 +20,7 @@ use crate::db::models::{ use crate::db::repository::{self, RepositoryError}; use crate::server::auth::Authenticated; use crate::server::messages::ApiError; -use crate::server::state::{DaemonCommand, SharedState, TaskUpdateNotification}; +use crate::server::state::{DaemonCommand, DaemonReauthStatus, SharedState, TaskUpdateNotification}; // ============================================================================= // Authentication Types @@ -4283,3 +4283,285 @@ pub async fn restart_daemon( }) .into_response() } + +// ============================================================================= +// Daemon Reauthorization +// ============================================================================= + +/// Response from the trigger reauth endpoint. +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct TriggerReauthResponse { + /// Whether the reauth command was sent successfully. + pub success: bool, + /// The daemon ID that received the reauth command. + #[serde(rename = "daemonId")] + pub daemon_id: Uuid, + /// Unique request ID for tracking this reauth flow. + #[serde(rename = "requestId")] + pub request_id: Uuid, +} + +/// Request body for submitting an auth code. +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct SubmitAuthCodeRequest { + /// The auth code obtained from the OAuth login flow. + pub code: String, + /// The request ID from the trigger reauth response. + #[serde(rename = "requestId")] + pub request_id: Uuid, +} + +/// Response from the submit auth code endpoint. +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct SubmitAuthCodeResponse { + /// Whether the auth code was sent to the daemon successfully. + pub success: bool, +} + +/// Response from the reauth status polling endpoint. +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct ReauthStatusResponse { + /// Current status of the reauth flow: "pending", "url_ready", "completed", "failed" + pub status: String, + /// OAuth login URL (present when status is "url_ready") + #[serde(rename = "loginUrl", skip_serializing_if = "Option::is_none")] + pub login_url: Option, + /// Error message (present when status is "failed") + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Trigger OAuth re-authentication on a daemon. +/// +/// Sends a reauth command to the specified daemon, which will spawn `claude setup-token` +/// and return the OAuth login URL via a status update. +#[utoipa::path( + post, + path = "/api/v1/mesh/daemons/{id}/reauth", + params( + ("id" = Uuid, Path, description = "Daemon ID") + ), + responses( + (status = 200, description = "Reauth command sent", body = TriggerReauthResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Daemon not found or not connected", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Mesh" +)] +pub async fn trigger_daemon_reauth( + State(state): State, + Authenticated(auth): Authenticated, + Path(id): Path, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify the daemon exists and belongs to this owner + match repository::get_daemon_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Daemon not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get daemon {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + // Check if daemon is connected + if !state.is_daemon_connected(id) { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new( + "DAEMON_NOT_CONNECTED", + "Daemon is not currently connected", + )), + ) + .into_response(); + } + + // Generate a unique request ID for this reauth flow + let request_id = Uuid::new_v4(); + + // Initialize the status as "pending" + state.set_daemon_reauth_status(id, request_id, "pending".to_string(), None, None); + + // Send reauth command to daemon + let command = DaemonCommand::TriggerReauth { request_id }; + if let Err(e) = state.send_daemon_command(id, command).await { + tracing::error!("Failed to send reauth command to daemon {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DAEMON_ERROR", e)), + ) + .into_response(); + } + + tracing::info!( + daemon_id = %id, + request_id = %request_id, + owner_id = %auth.owner_id, + "Reauth command sent to daemon" + ); + + Json(TriggerReauthResponse { + success: true, + daemon_id: id, + request_id, + }) + .into_response() +} + +/// Submit an OAuth auth code to a daemon's pending reauth flow. +/// +/// Sends the auth code to the daemon, which will forward it to the `claude setup-token` process. +#[utoipa::path( + post, + path = "/api/v1/mesh/daemons/{id}/reauth/code", + params( + ("id" = Uuid, Path, description = "Daemon ID") + ), + request_body = SubmitAuthCodeRequest, + responses( + (status = 200, description = "Auth code submitted", body = SubmitAuthCodeResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Daemon not found or not connected", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Mesh" +)] +pub async fn submit_daemon_auth_code( + State(state): State, + Authenticated(auth): Authenticated, + Path(id): Path, + Json(body): Json, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify the daemon exists and belongs to this owner + match repository::get_daemon_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Daemon not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get daemon {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + // Check if daemon is connected + if !state.is_daemon_connected(id) { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new( + "DAEMON_NOT_CONNECTED", + "Daemon is not currently connected", + )), + ) + .into_response(); + } + + // Send auth code command to daemon + let command = DaemonCommand::SubmitAuthCode { + request_id: body.request_id, + code: body.code, + }; + if let Err(e) = state.send_daemon_command(id, command).await { + tracing::error!("Failed to send auth code to daemon {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DAEMON_ERROR", e)), + ) + .into_response(); + } + + tracing::info!( + daemon_id = %id, + request_id = %body.request_id, + owner_id = %auth.owner_id, + "Auth code submitted to daemon" + ); + + Json(SubmitAuthCodeResponse { success: true }).into_response() +} + +/// Get the status of a daemon reauth request. +/// +/// Used by the frontend to poll for reauth status updates. +#[utoipa::path( + get, + path = "/api/v1/mesh/daemons/{id}/reauth/{request_id}/status", + params( + ("id" = Uuid, Path, description = "Daemon ID"), + ("request_id" = Uuid, Path, description = "Reauth request ID") + ), + responses( + (status = 200, description = "Reauth status", body = ReauthStatusResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Reauth request not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Mesh" +)] +pub async fn get_daemon_reauth_status( + State(state): State, + Authenticated(_auth): Authenticated, + Path((id, request_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + match state.get_daemon_reauth_status(id, request_id) { + Some(status) => Json(ReauthStatusResponse { + status: status.status, + login_url: status.login_url, + error: status.error, + }) + .into_response(), + None => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Reauth request not found")), + ) + .into_response(), + } +} diff --git a/makima/src/server/handlers/mesh_daemon.rs b/makima/src/server/handlers/mesh_daemon.rs index 743a1ca..30439a4 100644 --- a/makima/src/server/handlers/mesh_daemon.rs +++ b/makima/src/server/handlers/mesh_daemon.rs @@ -350,6 +350,18 @@ pub enum DaemonMessage { /// Hostname of the daemon requiring auth hostname: Option, }, + /// Reauth status update (response to TriggerReauth/SubmitAuthCode commands) + ReauthStatus { + #[serde(rename = "requestId")] + request_id: Uuid, + /// Status: "url_ready", "completed", "failed" + status: String, + /// OAuth login URL (present when status is "url_ready") + #[serde(rename = "loginUrl")] + login_url: Option, + /// Error message (present when status is "failed") + error: Option, + }, /// Response to RetryCompletionAction command CompletionActionResult { #[serde(rename = "taskId")] @@ -1622,6 +1634,25 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re "OAuth login URL available - user should open this in browser" ); } + Ok(DaemonMessage::ReauthStatus { request_id, status, login_url, error }) => { + tracing::info!( + daemon_id = %daemon_uuid, + request_id = %request_id, + status = %status, + login_url = ?login_url, + error = ?error, + "Daemon reauth status update" + ); + + // Store the reauth status for polling by the frontend + state.set_daemon_reauth_status( + daemon_uuid, + request_id, + status.clone(), + login_url.clone(), + error.clone(), + ); + } Ok(DaemonMessage::DaemonDirectories { working_directory, home_directory, worktrees_directory }) => { tracing::info!( daemon_id = %daemon_uuid, -- cgit v1.2.3