diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 0a243645..0bf62c77 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -30,6 +30,8 @@ use crate::container_view::{ContainerView, claude_has_session}; use crate::coordinator::Coordinator; use crate::lifecycle::{self, MANAGER_NAME}; +mod permissions; + #[derive(Clone)] struct AppState { coord: Arc, @@ -81,10 +83,16 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/request-spawn", post(post_request_spawn)) .route("/api/topology/set-parent", post(post_set_parent)) .route("/api/topology/set-parent-bulk", post(post_set_parent_bulk)) - .route("/api/tool-groups", get(get_tool_groups)) - .route("/api/tool-groups/{agent}", post(post_tool_groups)) - .route("/api/capabilities", get(get_capabilities)) - .route("/api/capabilities/{agent}", post(post_capabilities)) + .route("/api/tool-groups", get(permissions::get_tool_groups)) + .route( + "/api/tool-groups/{agent}", + post(permissions::post_tool_groups), + ) + .route("/api/capabilities", get(permissions::get_capabilities)) + .route( + "/api/capabilities/{agent}", + post(permissions::post_capabilities), + ) .route("/op-send", post(post_op_send)) .route("/meta-update", post(post_meta_update)) .route("/api/schedules", get(api_schedules).post(post_schedule_new)) @@ -2586,139 +2594,6 @@ async fn post_set_parent_bulk( } } -// ── tool-group endpoints ────────────────────────────────── - -#[derive(Serialize)] -struct ToolGroupsSnapshot { - /// Ordered list of all known tool-group names. Drives the column - /// headers in the capabilities table — the UI does not hard-code them. - groups: Vec<&'static str>, - /// Short description for each group name. Keys match `groups` entries. - descriptions: std::collections::BTreeMap<&'static str, &'static str>, - /// Per-agent assignment map. Absent agents use the role default - /// (agents: messaging+meta+inbox+execution; manager: all groups). - assignments: std::collections::BTreeMap>, -} - -async fn get_tool_groups(State(_state): State) -> axum::Json { - let groups = hive_sh4re::ToolGroup::ALL - .iter() - .map(|g| g.as_str()) - .collect(); - let descriptions = hive_sh4re::ToolGroup::ALL - .iter() - .map(|g| (g.as_str(), g.description())) - .collect(); - let assignments = crate::tool_groups::read(); - axum::Json(ToolGroupsSnapshot { - groups, - descriptions, - assignments, - }) -} - -#[derive(Deserialize)] -struct SetToolGroupsBody { - groups: Vec, -} - -async fn post_tool_groups( - State(state): State, - AxumPath(name): AxumPath, - axum::Json(body): axum::Json, -) -> Response { - let logical = strip_container_prefix(&name); - if let Some(reject) = guard_agent_name(&state, &logical).await { - return reject; - } - // Validate group names before queuing — fail fast so the operator - // sees the error immediately rather than waiting for the worker. - if let Err(e) = crate::tool_groups::validate_groups(&body.groups) { - return error_response(&format!("invalid tool-groups for {logical}: {e}")); - } - // Enqueue a PermChange so the JSON file write is serialised through - // the FIFO worker. Prevents concurrent batch-apply actions for - // different agents from racing on the shared tool-groups.json. - state.coord.rebuild_queue.enqueue_with_perm( - logical.clone(), - crate::rebuild_queue::QueueSource::Manual, - "tool-group change via permissions UI".to_owned(), - crate::rebuild_queue::PermPayload::ToolGroups { - groups: body.groups.clone(), - }, - ); - state.coord.emit_rebuild_queue_snapshot(); - tracing::info!(agent = %logical, groups = ?body.groups, "operator: set tool-groups via dashboard"); - (StatusCode::OK, "ok").into_response() -} - -// ── capability endpoints ────────────────────────────────────────────────── - -#[derive(Serialize)] -struct CapabilitiesSnapshot { - /// Ordered list of all known capability names. Drives the column - /// headers in the capabilities table — the UI does not hard-code them. - caps: Vec<&'static str>, - /// Short description for each capability name. Keys match `caps` entries. - descriptions: std::collections::BTreeMap<&'static str, &'static str>, - /// Per-agent capability grant map. Absent agents have no extra caps. - assignments: std::collections::BTreeMap>, -} - -async fn get_capabilities(State(_state): State) -> axum::Json { - use hive_sh4re::Capability; - let caps = Capability::ALL.iter().map(|c| c.as_str()).collect(); - let descriptions = Capability::ALL - .iter() - .map(|c| (c.as_str(), c.description())) - .collect(); - let assignments = crate::capabilities::read(); - axum::Json(CapabilitiesSnapshot { - caps, - descriptions, - assignments, - }) -} - -#[derive(Deserialize)] -struct SetCapabilitiesBody { - caps: Vec, -} - -async fn post_capabilities( - State(state): State, - AxumPath(name): AxumPath, - axum::Json(body): axum::Json, -) -> Response { - let logical = strip_container_prefix(&name); - if let Some(reject) = guard_agent_name(&state, &logical).await { - return reject; - } - let known: Vec<&str> = hive_sh4re::Capability::ALL - .iter() - .map(|c| c.as_str()) - .collect(); - for cap in &body.caps { - if !known.contains(&cap.as_str()) { - return error_response(&format!("unknown capability: {cap}")); - } - } - // Enqueue a PermChange so the JSON file write is serialised through - // the FIFO worker. Prevents concurrent batch-apply actions for - // different agents from racing on the shared capabilities.json. - state.coord.rebuild_queue.enqueue_with_perm( - logical.clone(), - crate::rebuild_queue::QueueSource::Manual, - "capability change via dashboard".to_owned(), - crate::rebuild_queue::PermPayload::Capabilities { - caps: body.caps.clone(), - }, - ); - state.coord.emit_rebuild_queue_snapshot(); - tracing::info!(agent = %logical, caps = ?body.caps, "operator: set capabilities via dashboard"); - (StatusCode::OK, "ok").into_response() -} - async fn post_rebuild(State(state): State, AxumPath(name): AxumPath) -> Response { let logical = strip_container_prefix(&name); if let Some(reject) = guard_agent_name(&state, &logical).await { diff --git a/hive-c0re/src/dashboard/permissions.rs b/hive-c0re/src/dashboard/permissions.rs new file mode 100644 index 00000000..b2923878 --- /dev/null +++ b/hive-c0re/src/dashboard/permissions.rs @@ -0,0 +1,148 @@ +//! Tool-group + capability permission endpoints for the dashboard. +//! +//! Read endpoints return the full set of known groups/capabilities plus +//! descriptions and the per-agent assignment map (the UI never hard-codes +//! the lists). Write endpoints validate, then enqueue a `PermChange` so the +//! JSON file write is serialised through the FIFO rebuild worker. + +use axum::{ + extract::{Path as AxumPath, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; + +use super::{AppState, error_response, guard_agent_name, strip_container_prefix}; + +#[derive(Serialize)] +pub(super) struct ToolGroupsSnapshot { + /// Ordered list of all known tool-group names. Drives the column + /// headers in the capabilities table — the UI does not hard-code them. + groups: Vec<&'static str>, + /// Short description for each group name. Keys match `groups` entries. + descriptions: std::collections::BTreeMap<&'static str, &'static str>, + /// Per-agent assignment map. Absent agents use the role default + /// (agents: messaging+meta+inbox+execution; manager: all groups). + assignments: std::collections::BTreeMap>, +} + +pub(super) async fn get_tool_groups( + State(_state): State, +) -> axum::Json { + let groups = hive_sh4re::ToolGroup::ALL + .iter() + .map(|g| g.as_str()) + .collect(); + let descriptions = hive_sh4re::ToolGroup::ALL + .iter() + .map(|g| (g.as_str(), g.description())) + .collect(); + let assignments = crate::tool_groups::read(); + axum::Json(ToolGroupsSnapshot { + groups, + descriptions, + assignments, + }) +} + +#[derive(Deserialize)] +pub(super) struct SetToolGroupsBody { + groups: Vec, +} + +pub(super) async fn post_tool_groups( + State(state): State, + AxumPath(name): AxumPath, + axum::Json(body): axum::Json, +) -> Response { + let logical = strip_container_prefix(&name); + if let Some(reject) = guard_agent_name(&state, &logical).await { + return reject; + } + // Validate group names before queuing — fail fast so the operator + // sees the error immediately rather than waiting for the worker. + if let Err(e) = crate::tool_groups::validate_groups(&body.groups) { + return error_response(&format!("invalid tool-groups for {logical}: {e}")); + } + // Enqueue a PermChange so the JSON file write is serialised through + // the FIFO worker. Prevents concurrent batch-apply actions for + // different agents from racing on the shared tool-groups.json. + state.coord.rebuild_queue.enqueue_with_perm( + logical.clone(), + crate::rebuild_queue::QueueSource::Manual, + "tool-group change via permissions UI".to_owned(), + crate::rebuild_queue::PermPayload::ToolGroups { + groups: body.groups.clone(), + }, + ); + state.coord.emit_rebuild_queue_snapshot(); + tracing::info!(agent = %logical, groups = ?body.groups, "operator: set tool-groups via dashboard"); + (StatusCode::OK, "ok").into_response() +} + +#[derive(Serialize)] +pub(super) struct CapabilitiesSnapshot { + /// Ordered list of all known capability names. Drives the column + /// headers in the capabilities table — the UI does not hard-code them. + caps: Vec<&'static str>, + /// Short description for each capability name. Keys match `caps` entries. + descriptions: std::collections::BTreeMap<&'static str, &'static str>, + /// Per-agent capability grant map. Absent agents have no extra caps. + assignments: std::collections::BTreeMap>, +} + +pub(super) async fn get_capabilities( + State(_state): State, +) -> axum::Json { + use hive_sh4re::Capability; + let caps = Capability::ALL.iter().map(|c| c.as_str()).collect(); + let descriptions = Capability::ALL + .iter() + .map(|c| (c.as_str(), c.description())) + .collect(); + let assignments = crate::capabilities::read(); + axum::Json(CapabilitiesSnapshot { + caps, + descriptions, + assignments, + }) +} + +#[derive(Deserialize)] +pub(super) struct SetCapabilitiesBody { + caps: Vec, +} + +pub(super) async fn post_capabilities( + State(state): State, + AxumPath(name): AxumPath, + axum::Json(body): axum::Json, +) -> Response { + let logical = strip_container_prefix(&name); + if let Some(reject) = guard_agent_name(&state, &logical).await { + return reject; + } + let known: Vec<&str> = hive_sh4re::Capability::ALL + .iter() + .map(|c| c.as_str()) + .collect(); + for cap in &body.caps { + if !known.contains(&cap.as_str()) { + return error_response(&format!("unknown capability: {cap}")); + } + } + // Enqueue a PermChange so the JSON file write is serialised through + // the FIFO worker. Prevents concurrent batch-apply actions for + // different agents from racing on the shared capabilities.json. + state.coord.rebuild_queue.enqueue_with_perm( + logical.clone(), + crate::rebuild_queue::QueueSource::Manual, + "capability change via dashboard".to_owned(), + crate::rebuild_queue::PermPayload::Capabilities { + caps: body.caps.clone(), + }, + ); + state.coord.emit_rebuild_queue_snapshot(); + tracing::info!(agent = %logical, caps = ?body.caps, "operator: set capabilities via dashboard"); + (StatusCode::OK, "ok").into_response() +}