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 ++++ makima/src/server/mod.rs | 3 + makima/src/server/state.rs | 60 +++++++ 4 files changed, 377 insertions(+), 1 deletion(-) (limited to 'makima/src/server') 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, diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index e0f8e7d..b84b90e 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -98,6 +98,9 @@ pub fn make_router(state: SharedState) -> Router { .route("/mesh/daemons/directories", get(mesh::get_daemon_directories)) .route("/mesh/daemons/{id}", get(mesh::get_daemon)) .route("/mesh/daemons/{id}/restart", post(mesh::restart_daemon)) + .route("/mesh/daemons/{id}/reauth", post(mesh::trigger_daemon_reauth)) + .route("/mesh/daemons/{id}/reauth/code", post(mesh::submit_daemon_auth_code)) + .route("/mesh/daemons/{id}/reauth/{request_id}/status", get(mesh::get_daemon_reauth_status)) // Merge endpoints for orchestrators .route("/mesh/tasks/{id}/branches", get(mesh_merge::list_branches)) .route("/mesh/tasks/{id}/merge/start", post(mesh_merge::merge_start)) diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs index 15fec6b..5c5e24f 100644 --- a/makima/src/server/state.rs +++ b/makima/src/server/state.rs @@ -521,6 +521,19 @@ pub enum DaemonCommand { /// Restart the daemon process RestartDaemon, + /// Trigger OAuth re-authentication on this daemon + TriggerReauth { + #[serde(rename = "requestId")] + request_id: Uuid, + }, + + /// Submit auth code for pending reauth + SubmitAuthCode { + #[serde(rename = "requestId")] + request_id: Uuid, + code: String, + }, + /// Apply a patch to a task's worktree (for cross-daemon merge). /// Sent by server when routing MergePatchToSupervisor to the supervisor's daemon. ApplyPatchToWorktree { @@ -562,6 +575,15 @@ pub struct DaemonConnectionInfo { pub worktrees_directory: Option, } +/// Status of a daemon reauth request (stored in state for polling). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct DaemonReauthStatus { + pub request_id: Uuid, + pub status: String, + pub login_url: Option, + pub error: Option, +} + /// Configuration paths for ML models (used for lazy loading). #[derive(Clone)] pub struct ModelConfig { @@ -616,6 +638,8 @@ pub struct AppState { pub pending_worktree_info: DashMap>, /// Lazily-loaded TTS engine (initialized on first Speak connection) pub tts_engine: OnceCell>, + /// Daemon reauth status storage (keyed by (daemon_id, request_id)) + pub daemon_reauth_status: DashMap<(Uuid, Uuid), DaemonReauthStatus>, } impl AppState { @@ -694,6 +718,7 @@ impl AppState { jwt_verifier, pending_worktree_info: DashMap::new(), tts_engine: OnceCell::new(), + daemon_reauth_status: DashMap::new(), } } @@ -1200,6 +1225,41 @@ impl AppState { tracing::info!(task_id = %task_id, "Revoked tool key"); } + // ========================================================================= + // Daemon Reauth Status + // ========================================================================= + + /// Store a daemon reauth status update (from daemon's ReauthStatus message). + pub fn set_daemon_reauth_status( + &self, + daemon_id: Uuid, + request_id: Uuid, + status: String, + login_url: Option, + error: Option, + ) { + self.daemon_reauth_status.insert( + (daemon_id, request_id), + DaemonReauthStatus { + request_id, + status, + login_url, + error, + }, + ); + } + + /// Get a daemon reauth status (for frontend polling). + pub fn get_daemon_reauth_status( + &self, + daemon_id: Uuid, + request_id: Uuid, + ) -> Option { + self.daemon_reauth_status + .get(&(daemon_id, request_id)) + .map(|entry| entry.value().clone()) + } + // ========================================================================= // Supervisor Notifications // ========================================================================= -- cgit v1.2.3