diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index d2f66392c..c10814068 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -1,9986 +1,10212 @@ -//! Route handlers for the OpenFang API. - -use crate::types::*; -use axum::extract::{Path, Query, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::Json; -use dashmap::DashMap; -use openfang_kernel::triggers::{TriggerId, TriggerPattern}; -use openfang_kernel::workflow::{ - ErrorMode, StepAgent, StepMode, Workflow, WorkflowId, WorkflowStep, -}; -use openfang_kernel::OpenFangKernel; -use openfang_runtime::kernel_handle::KernelHandle; -use openfang_runtime::tool_runner::builtin_tool_definitions; -use openfang_types::agent::{AgentId, AgentIdentity, AgentManifest}; -use std::collections::HashMap; -use std::sync::{Arc, LazyLock}; -use std::time::Instant; - -/// Shared application state. -/// -/// The kernel is wrapped in Arc so it can serve as both the main kernel -/// and the KernelHandle for inter-agent tool access. -pub struct AppState { - pub kernel: Arc, - pub started_at: Instant, - /// Optional peer registry for OFP mesh networking status. - pub peer_registry: Option>, - /// Channel bridge manager — held behind a Mutex so it can be swapped on hot-reload. - pub bridge_manager: tokio::sync::Mutex>, - /// Live channel config — updated on every hot-reload so list_channels() reflects reality. - pub channels_config: tokio::sync::RwLock, - /// Notify handle to trigger graceful HTTP server shutdown from the API. - pub shutdown_notify: Arc, - /// ClawHub response cache — prevents 429 rate limiting on rapid dashboard refreshes. - /// Maps cache key → (fetched_at, response_json) with 120s TTL. - pub clawhub_cache: DashMap, -} - -/// POST /api/agents — Spawn a new agent. -pub async fn spawn_agent( - State(state): State>, - Json(req): Json, -) -> impl IntoResponse { - // SECURITY: Reject oversized manifests to prevent parser memory exhaustion. - const MAX_MANIFEST_SIZE: usize = 1024 * 1024; // 1MB - if req.manifest_toml.len() > MAX_MANIFEST_SIZE { - return ( - StatusCode::PAYLOAD_TOO_LARGE, - Json(serde_json::json!({"error": "Manifest too large (max 1MB)"})), - ); - } - - // SECURITY: Verify Ed25519 signature when a signed manifest is provided - if let Some(ref signed_json) = req.signed_manifest { - match state.kernel.verify_signed_manifest(signed_json) { - Ok(verified_toml) => { - // Ensure the signed manifest matches the provided manifest_toml - if verified_toml.trim() != req.manifest_toml.trim() { - tracing::warn!("Signed manifest content does not match manifest_toml"); - return ( - StatusCode::BAD_REQUEST, - Json( - serde_json::json!({"error": "Signed manifest content does not match manifest_toml"}), - ), - ); - } - } - Err(e) => { - tracing::warn!("Manifest signature verification failed: {e}"); - state.kernel.audit_log.record( - "system", - openfang_runtime::audit::AuditAction::AuthAttempt, - "manifest signature verification failed", - format!("error: {e}"), - ); - return ( - StatusCode::FORBIDDEN, - Json(serde_json::json!({"error": "Manifest signature verification failed"})), - ); - } - } - } - - let manifest: AgentManifest = match toml::from_str(&req.manifest_toml) { - Ok(m) => m, - Err(e) => { - tracing::warn!("Invalid manifest TOML: {e}"); - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid manifest format"})), - ); - } - }; - - let name = manifest.name.clone(); - match state.kernel.spawn_agent(manifest) { - Ok(id) => ( - StatusCode::CREATED, - Json(serde_json::json!(SpawnResponse { - agent_id: id.to_string(), - name, - })), - ), - Err(e) => { - tracing::warn!("Spawn failed: {e}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "Agent spawn failed"})), - ) - } - } -} - -/// GET /api/agents — List all agents. -pub async fn list_agents(State(state): State>) -> impl IntoResponse { - // Snapshot catalog once for enrichment - let catalog = state.kernel.model_catalog.read().ok(); - let dm = &state.kernel.config.default_model; - - let agents: Vec = state - .kernel - .registry - .list() - .into_iter() - .map(|e| { - // Resolve "default" provider/model to actual kernel defaults - let provider = if e.manifest.model.provider.is_empty() - || e.manifest.model.provider == "default" - { - dm.provider.as_str() - } else { - e.manifest.model.provider.as_str() - }; - let model = if e.manifest.model.model.is_empty() - || e.manifest.model.model == "default" - { - dm.model.as_str() - } else { - e.manifest.model.model.as_str() - }; - - // Enrich from catalog - let (tier, auth_status) = catalog - .as_ref() - .map(|cat| { - let tier = cat - .find_model(model) - .map(|m| format!("{:?}", m.tier).to_lowercase()) - .unwrap_or_else(|| "unknown".to_string()); - let auth = cat - .get_provider(provider) - .map(|p| format!("{:?}", p.auth_status).to_lowercase()) - .unwrap_or_else(|| "unknown".to_string()); - (tier, auth) - }) - .unwrap_or(("unknown".to_string(), "unknown".to_string())); - - let ready = matches!(e.state, openfang_types::agent::AgentState::Running) - && auth_status != "missing"; - - serde_json::json!({ - "id": e.id.to_string(), - "name": e.name, - "state": format!("{:?}", e.state), - "mode": e.mode, - "created_at": e.created_at.to_rfc3339(), - "last_active": e.last_active.to_rfc3339(), - "model_provider": provider, - "model_name": model, - "model_tier": tier, - "auth_status": auth_status, - "ready": ready, - "profile": e.manifest.profile, - "identity": { - "emoji": e.identity.emoji, - "avatar_url": e.identity.avatar_url, - "color": e.identity.color, - }, - }) - }) - .collect(); - - Json(agents) -} - -/// Resolve uploaded file attachments into ContentBlock::Image blocks. -/// -/// Reads each file from the upload directory, base64-encodes it, and -/// returns image content blocks ready to insert into a session message. -pub fn resolve_attachments( - attachments: &[AttachmentRef], -) -> Vec { - use base64::Engine; - - let upload_dir = std::env::temp_dir().join("openfang_uploads"); - let mut blocks = Vec::new(); - - for att in attachments { - // Look up metadata from the upload registry - let meta = UPLOAD_REGISTRY.get(&att.file_id); - let content_type = if let Some(ref m) = meta { - m.content_type.clone() - } else if !att.content_type.is_empty() { - att.content_type.clone() - } else { - continue; // Skip unknown attachments - }; - - // Only process image types - if !content_type.starts_with("image/") { - continue; - } - - // Validate file_id is a UUID to prevent path traversal - if uuid::Uuid::parse_str(&att.file_id).is_err() { - continue; - } - - let file_path = upload_dir.join(&att.file_id); - match std::fs::read(&file_path) { - Ok(data) => { - let b64 = base64::engine::general_purpose::STANDARD.encode(&data); - blocks.push(openfang_types::message::ContentBlock::Image { - media_type: content_type, - data: b64, - }); - } - Err(e) => { - tracing::warn!(file_id = %att.file_id, error = %e, "Failed to read upload for attachment"); - } - } - } - - blocks -} - -/// Pre-insert image attachments into an agent's session so the LLM can see them. -/// -/// This injects image content blocks into the session BEFORE the kernel -/// adds the text user message, so the LLM receives: [..., User(images), User(text)]. -pub fn inject_attachments_into_session( - kernel: &OpenFangKernel, - agent_id: AgentId, - image_blocks: Vec, -) { - use openfang_types::message::{Message, MessageContent, Role}; - - let entry = match kernel.registry.get(agent_id) { - Some(e) => e, - None => return, - }; - - let mut session = match kernel.memory.get_session(entry.session_id) { - Ok(Some(s)) => s, - _ => openfang_memory::session::Session { - id: entry.session_id, - agent_id, - messages: Vec::new(), - context_window_tokens: 0, - label: None, - }, - }; - - session.messages.push(Message { - role: Role::User, - content: MessageContent::Blocks(image_blocks), - }); - - if let Err(e) = kernel.memory.save_session(&session) { - tracing::warn!(error = %e, "Failed to save session with image attachments"); - } -} - -/// POST /api/agents/:id/message — Send a message to an agent. -pub async fn send_message( - State(state): State>, - Path(id): Path, - Json(req): Json, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ); - } - }; - - // SECURITY: Reject oversized messages to prevent OOM / LLM token abuse. - const MAX_MESSAGE_SIZE: usize = 64 * 1024; // 64KB - if req.message.len() > MAX_MESSAGE_SIZE { - return ( - StatusCode::PAYLOAD_TOO_LARGE, - Json(serde_json::json!({"error": "Message too large (max 64KB)"})), - ); - } - - // Resolve file attachments into image content blocks - if !req.attachments.is_empty() { - let image_blocks = resolve_attachments(&req.attachments); - if !image_blocks.is_empty() { - inject_attachments_into_session(&state.kernel, agent_id, image_blocks); - } - } - - let kernel_handle: Arc = state.kernel.clone() as Arc; - match state - .kernel - .send_message_with_handle(agent_id, &req.message, Some(kernel_handle)) - .await - { - Ok(result) => { - // Guard: ensure we never return an empty response to the client - let response = if result.response.trim().is_empty() { - format!( - "[The agent completed processing but returned no text response. ({} in / {} out | {} iter)]", - result.total_usage.input_tokens, - result.total_usage.output_tokens, - result.iterations, - ) - } else { - result.response - }; - ( - StatusCode::OK, - Json(serde_json::json!(MessageResponse { - response, - input_tokens: result.total_usage.input_tokens, - output_tokens: result.total_usage.output_tokens, - iterations: result.iterations, - cost_usd: result.cost_usd, - })), - ) - } - Err(e) => { - tracing::warn!("send_message failed for agent {id}: {e}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Message delivery failed: {e}")})), - ) - } - } -} - -/// GET /api/agents/:id/session — Get agent session (conversation history). -pub async fn get_agent_session( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ); - } - }; - - let entry = match state.kernel.registry.get(agent_id) { - Some(e) => e, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ); - } - }; - - match state.kernel.memory.get_session(entry.session_id) { - Ok(Some(session)) => { - let messages: Vec = session - .messages - .iter() - .filter_map(|m| { - let mut tools: Vec = Vec::new(); - let content = match &m.content { - openfang_types::message::MessageContent::Text(t) => t.clone(), - openfang_types::message::MessageContent::Blocks(blocks) => { - // Extract human-readable text and tool info from blocks - let mut texts = Vec::new(); - for b in blocks { - match b { - openfang_types::message::ContentBlock::Text { text } => { - texts.push(text.clone()); - } - openfang_types::message::ContentBlock::Image { .. } => { - texts.push("[Image]".to_string()); - } - openfang_types::message::ContentBlock::ToolUse { - name, .. - } => { - tools.push(serde_json::json!({ - "name": name, - "running": false, - "expanded": false, - })); - } - openfang_types::message::ContentBlock::ToolResult { - content: result, - is_error, - .. - } => { - // Attach result to the most recent tool without a result - if let Some(last_tool) = tools.last_mut() { - let preview: String = - result.chars().take(300).collect(); - last_tool["result"] = - serde_json::Value::String(preview); - last_tool["is_error"] = - serde_json::Value::Bool(*is_error); - } - } - _ => {} - } - } - texts.join("\n") - } - }; - // Skip messages that are purely tool results (User role with only ToolResult blocks) - if content.is_empty() && tools.is_empty() { - return None; - } - let mut msg = serde_json::json!({ - "role": format!("{:?}", m.role), - "content": content, - }); - if !tools.is_empty() { - msg["tools"] = serde_json::Value::Array(tools); - } - Some(msg) - }) - .collect(); - ( - StatusCode::OK, - Json(serde_json::json!({ - "session_id": session.id.0.to_string(), - "agent_id": session.agent_id.0.to_string(), - "message_count": session.messages.len(), - "context_window_tokens": session.context_window_tokens, - "label": session.label, - "messages": messages, - })), - ) - } - Ok(None) => ( - StatusCode::OK, - Json(serde_json::json!({ - "session_id": entry.session_id.0.to_string(), - "agent_id": agent_id.to_string(), - "message_count": 0, - "context_window_tokens": 0, - "messages": [], - })), - ), - Err(e) => { - tracing::warn!("Session load failed for agent {id}: {e}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "Session load failed"})), - ) - } - } -} - -/// DELETE /api/agents/:id — Kill an agent. -pub async fn kill_agent( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ); - } - }; - - match state.kernel.kill_agent(agent_id) { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "killed", "agent_id": id})), - ), - Err(e) => { - tracing::warn!("kill_agent failed for {id}: {e}"); - ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found or already terminated"})), - ) - } - } -} - -/// GET /api/status — Kernel status. -pub async fn status(State(state): State>) -> impl IntoResponse { - let agents: Vec = state - .kernel - .registry - .list() - .into_iter() - .map(|e| { - serde_json::json!({ - "id": e.id.to_string(), - "name": e.name, - "state": format!("{:?}", e.state), - "mode": e.mode, - "created_at": e.created_at.to_rfc3339(), - "model_provider": e.manifest.model.provider, - "model_name": e.manifest.model.model, - "profile": e.manifest.profile, - }) - }) - .collect(); - - let uptime = state.started_at.elapsed().as_secs(); - let agent_count = agents.len(); - - Json(serde_json::json!({ - "status": "running", - "agent_count": agent_count, - "default_provider": state.kernel.config.default_model.provider, - "default_model": state.kernel.config.default_model.model, - "uptime_seconds": uptime, - "api_listen": state.kernel.config.api_listen, - "home_dir": state.kernel.config.home_dir.display().to_string(), - "log_level": state.kernel.config.log_level, - "network_enabled": state.kernel.config.network_enabled, - "agents": agents, - })) -} - -/// POST /api/shutdown — Graceful shutdown. -pub async fn shutdown(State(state): State>) -> impl IntoResponse { - tracing::info!("Shutdown requested via API"); - // SECURITY: Record shutdown in audit trail - state.kernel.audit_log.record( - "system", - openfang_runtime::audit::AuditAction::ConfigChange, - "shutdown requested via API", - "ok", - ); - state.kernel.shutdown(); - // Signal the HTTP server to initiate graceful shutdown so the process exits. - state.shutdown_notify.notify_one(); - Json(serde_json::json!({"status": "shutting_down"})) -} - -// --------------------------------------------------------------------------- -// Workflow routes -// --------------------------------------------------------------------------- - -/// POST /api/workflows — Register a new workflow. -pub async fn create_workflow( - State(state): State>, - Json(req): Json, -) -> impl IntoResponse { - let name = req["name"].as_str().unwrap_or("unnamed").to_string(); - let description = req["description"].as_str().unwrap_or("").to_string(); - - let steps_json = match req["steps"].as_array() { - Some(s) => s, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Missing 'steps' array"})), - ); - } - }; - - let mut steps = Vec::new(); - for s in steps_json { - let step_name = s["name"].as_str().unwrap_or("step").to_string(); - let agent = if let Some(id) = s["agent_id"].as_str() { - StepAgent::ById { id: id.to_string() } - } else if let Some(name) = s["agent_name"].as_str() { - StepAgent::ByName { - name: name.to_string(), - } - } else { - return ( - StatusCode::BAD_REQUEST, - Json( - serde_json::json!({"error": format!("Step '{}' needs 'agent_id' or 'agent_name'", step_name)}), - ), - ); - }; - - let mode = match s["mode"].as_str().unwrap_or("sequential") { - "fan_out" => StepMode::FanOut, - "collect" => StepMode::Collect, - "conditional" => StepMode::Conditional { - condition: s["condition"].as_str().unwrap_or("").to_string(), - }, - "loop" => StepMode::Loop { - max_iterations: s["max_iterations"].as_u64().unwrap_or(5) as u32, - until: s["until"].as_str().unwrap_or("").to_string(), - }, - _ => StepMode::Sequential, - }; - - let error_mode = match s["error_mode"].as_str().unwrap_or("fail") { - "skip" => ErrorMode::Skip, - "retry" => ErrorMode::Retry { - max_retries: s["max_retries"].as_u64().unwrap_or(3) as u32, - }, - _ => ErrorMode::Fail, - }; - - steps.push(WorkflowStep { - name: step_name, - agent, - prompt_template: s["prompt"].as_str().unwrap_or("{{input}}").to_string(), - mode, - timeout_secs: s["timeout_secs"].as_u64().unwrap_or(120), - error_mode, - output_var: s["output_var"].as_str().map(String::from), - }); - } - - let workflow = Workflow { - id: WorkflowId::new(), - name, - description, - steps, - created_at: chrono::Utc::now(), - }; - - let id = state.kernel.register_workflow(workflow).await; - ( - StatusCode::CREATED, - Json(serde_json::json!({"workflow_id": id.to_string()})), - ) -} - -/// GET /api/workflows — List all workflows. -pub async fn list_workflows(State(state): State>) -> impl IntoResponse { - let workflows = state.kernel.workflows.list_workflows().await; - let list: Vec = workflows - .iter() - .map(|w| { - serde_json::json!({ - "id": w.id.to_string(), - "name": w.name, - "description": w.description, - "steps": w.steps.len(), - "created_at": w.created_at.to_rfc3339(), - }) - }) - .collect(); - Json(list) -} - -/// POST /api/workflows/:id/run — Execute a workflow. -pub async fn run_workflow( - State(state): State>, - Path(id): Path, - Json(req): Json, -) -> impl IntoResponse { - let workflow_id = WorkflowId(match id.parse() { - Ok(u) => u, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid workflow ID"})), - ); - } - }); - - let input = req["input"].as_str().unwrap_or("").to_string(); - - match state.kernel.run_workflow(workflow_id, input).await { - Ok((run_id, output)) => ( - StatusCode::OK, - Json(serde_json::json!({ - "run_id": run_id.to_string(), - "output": output, - "status": "completed", - })), - ), - Err(e) => { - tracing::warn!("Workflow run failed for {id}: {e}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "Workflow execution failed"})), - ) - } - } -} - -/// GET /api/workflows/:id/runs — List runs for a workflow. -pub async fn list_workflow_runs( - State(state): State>, - Path(_id): Path, -) -> impl IntoResponse { - let runs = state.kernel.workflows.list_runs(None).await; - let list: Vec = runs - .iter() - .map(|r| { - serde_json::json!({ - "id": r.id.to_string(), - "workflow_name": r.workflow_name, - "state": serde_json::to_value(&r.state).unwrap_or_default(), - "steps_completed": r.step_results.len(), - "started_at": r.started_at.to_rfc3339(), - "completed_at": r.completed_at.map(|t| t.to_rfc3339()), - }) - }) - .collect(); - Json(list) -} - -// --------------------------------------------------------------------------- -// Trigger routes -// --------------------------------------------------------------------------- - -/// POST /api/triggers — Register a new event trigger. -pub async fn create_trigger( - State(state): State>, - Json(req): Json, -) -> impl IntoResponse { - let agent_id_str = match req["agent_id"].as_str() { - Some(id) => id, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Missing 'agent_id'"})), - ); - } - }; - - let agent_id: AgentId = match agent_id_str.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent_id"})), - ); - } - }; - - let pattern: TriggerPattern = match req.get("pattern") { - Some(p) => match serde_json::from_value(p.clone()) { - Ok(pat) => pat, - Err(e) => { - tracing::warn!("Invalid trigger pattern: {e}"); - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid trigger pattern"})), - ); - } - }, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Missing 'pattern'"})), - ); - } - }; - - let prompt_template = req["prompt_template"] - .as_str() - .unwrap_or("Event: {{event}}") - .to_string(); - let max_fires = req["max_fires"].as_u64().unwrap_or(0); - - match state - .kernel - .register_trigger(agent_id, pattern, prompt_template, max_fires) - { - Ok(trigger_id) => ( - StatusCode::CREATED, - Json(serde_json::json!({ - "trigger_id": trigger_id.to_string(), - "agent_id": agent_id.to_string(), - })), - ), - Err(e) => { - tracing::warn!("Trigger registration failed: {e}"); - ( - StatusCode::NOT_FOUND, - Json( - serde_json::json!({"error": "Trigger registration failed (agent not found?)"}), - ), - ) - } - } -} - -/// GET /api/triggers — List all triggers (optionally filter by ?agent_id=...). -pub async fn list_triggers( - State(state): State>, - Query(params): Query>, -) -> impl IntoResponse { - let agent_filter = params - .get("agent_id") - .and_then(|id| id.parse::().ok()); - - let triggers = state.kernel.list_triggers(agent_filter); - let list: Vec = triggers - .iter() - .map(|t| { - serde_json::json!({ - "id": t.id.to_string(), - "agent_id": t.agent_id.to_string(), - "pattern": serde_json::to_value(&t.pattern).unwrap_or_default(), - "prompt_template": t.prompt_template, - "enabled": t.enabled, - "fire_count": t.fire_count, - "max_fires": t.max_fires, - "created_at": t.created_at.to_rfc3339(), - }) - }) - .collect(); - Json(list) -} - -/// DELETE /api/triggers/:id — Remove a trigger. -pub async fn delete_trigger( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let trigger_id = TriggerId(match id.parse() { - Ok(u) => u, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid trigger ID"})), - ); - } - }); - - if state.kernel.remove_trigger(trigger_id) { - ( - StatusCode::OK, - Json(serde_json::json!({"status": "removed", "trigger_id": id})), - ) - } else { - ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Trigger not found"})), - ) - } -} - -// --------------------------------------------------------------------------- -// Profile + Mode endpoints -// --------------------------------------------------------------------------- - -/// GET /api/profiles — List all tool profiles and their tool lists. -pub async fn list_profiles() -> impl IntoResponse { - use openfang_types::agent::ToolProfile; - - let profiles = [ - ("minimal", ToolProfile::Minimal), - ("coding", ToolProfile::Coding), - ("research", ToolProfile::Research), - ("messaging", ToolProfile::Messaging), - ("automation", ToolProfile::Automation), - ("full", ToolProfile::Full), - ]; - - let result: Vec = profiles - .iter() - .map(|(name, profile)| { - serde_json::json!({ - "name": name, - "tools": profile.tools(), - }) - }) - .collect(); - - Json(result) -} - -/// PUT /api/agents/:id/mode — Change an agent's operational mode. -pub async fn set_agent_mode( - State(state): State>, - Path(id): Path, - Json(body): Json, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ); - } - }; - - match state.kernel.registry.set_mode(agent_id, body.mode) { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "updated", - "agent_id": id, - "mode": body.mode, - })), - ), - Err(_) => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ), - } -} - -// --------------------------------------------------------------------------- -// Version endpoint -// --------------------------------------------------------------------------- - -/// GET /api/version — Build & version info. -pub async fn version() -> impl IntoResponse { - Json(serde_json::json!({ - "name": "openfang", - "version": env!("CARGO_PKG_VERSION"), - "build_date": option_env!("BUILD_DATE").unwrap_or("dev"), - "git_sha": option_env!("GIT_SHA").unwrap_or("unknown"), - "rust_version": option_env!("RUSTC_VERSION").unwrap_or("unknown"), - "platform": std::env::consts::OS, - "arch": std::env::consts::ARCH, - })) -} - -// --------------------------------------------------------------------------- -// Single agent detail + SSE streaming -// --------------------------------------------------------------------------- - -/// GET /api/agents/:id — Get a single agent's detailed info. -pub async fn get_agent( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ); - } - }; - - let entry = match state.kernel.registry.get(agent_id) { - Some(e) => e, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ); - } - }; - - ( - StatusCode::OK, - Json(serde_json::json!({ - "id": entry.id.to_string(), - "name": entry.name, - "state": format!("{:?}", entry.state), - "mode": entry.mode, - "profile": entry.manifest.profile, - "created_at": entry.created_at.to_rfc3339(), - "session_id": entry.session_id.0.to_string(), - "model": { - "provider": entry.manifest.model.provider, - "model": entry.manifest.model.model, - }, - "capabilities": { - "tools": entry.manifest.capabilities.tools, - "network": entry.manifest.capabilities.network, - }, - "description": entry.manifest.description, - "tags": entry.manifest.tags, - "identity": { - "emoji": entry.identity.emoji, - "avatar_url": entry.identity.avatar_url, - "color": entry.identity.color, - }, - "skills": entry.manifest.skills, - "skills_mode": if entry.manifest.skills.is_empty() { "all" } else { "allowlist" }, - "mcp_servers": entry.manifest.mcp_servers, - "mcp_servers_mode": if entry.manifest.mcp_servers.is_empty() { "all" } else { "allowlist" }, - })), - ) -} - -/// POST /api/agents/:id/message/stream — SSE streaming response. -pub async fn send_message_stream( - State(state): State>, - Path(id): Path, - Json(req): Json, -) -> axum::response::Response { - use axum::response::sse::{Event, Sse}; - use futures::stream; - use openfang_runtime::llm_driver::StreamEvent; - - // SECURITY: Reject oversized messages to prevent OOM / LLM token abuse. - const MAX_MESSAGE_SIZE: usize = 64 * 1024; // 64KB - if req.message.len() > MAX_MESSAGE_SIZE { - return ( - StatusCode::PAYLOAD_TOO_LARGE, - Json(serde_json::json!({"error": "Message too large (max 64KB)"})), - ) - .into_response(); - } - - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ) - .into_response(); - } - }; - - if state.kernel.registry.get(agent_id).is_none() { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ) - .into_response(); - } - - let kernel_handle: Arc = state.kernel.clone() as Arc; - let (rx, _handle) = - match state - .kernel - .send_message_streaming(agent_id, &req.message, Some(kernel_handle)) - { - Ok(pair) => pair, - Err(e) => { - tracing::warn!("Streaming message failed for agent {id}: {e}"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "Streaming message failed"})), - ) - .into_response(); - } - }; - - let sse_stream = stream::unfold(rx, |mut rx| async move { - match rx.recv().await { - Some(event) => { - let sse_event: Result = Ok(match event { - StreamEvent::TextDelta { text } => Event::default() - .event("chunk") - .json_data(serde_json::json!({"content": text, "done": false})) - .unwrap_or_else(|_| Event::default().data("error")), - StreamEvent::ToolUseStart { name, .. } => Event::default() - .event("tool_use") - .json_data(serde_json::json!({"tool": name})) - .unwrap_or_else(|_| Event::default().data("error")), - StreamEvent::ToolUseEnd { name, input, .. } => Event::default() - .event("tool_result") - .json_data(serde_json::json!({"tool": name, "input": input})) - .unwrap_or_else(|_| Event::default().data("error")), - StreamEvent::ContentComplete { usage, .. } => Event::default() - .event("done") - .json_data(serde_json::json!({ - "done": true, - "usage": { - "input_tokens": usage.input_tokens, - "output_tokens": usage.output_tokens, - } - })) - .unwrap_or_else(|_| Event::default().data("error")), - StreamEvent::PhaseChange { phase, detail } => Event::default() - .event("phase") - .json_data(serde_json::json!({ - "phase": phase, - "detail": detail, - })) - .unwrap_or_else(|_| Event::default().data("error")), - _ => Event::default().comment("skip"), - }); - Some((sse_event, rx)) - } - None => None, - } - }); - - Sse::new(sse_stream).into_response() -} - -// --------------------------------------------------------------------------- -// Channel status endpoints — data-driven registry for all 40 adapters -// --------------------------------------------------------------------------- - -/// Field type for the channel configuration form. -#[derive(Clone, Copy, PartialEq)] -enum FieldType { - Secret, - Text, - Number, - List, -} - -impl FieldType { - fn as_str(self) -> &'static str { - match self { - Self::Secret => "secret", - Self::Text => "text", - Self::Number => "number", - Self::List => "list", - } - } -} - -/// A single configurable field for a channel adapter. -#[derive(Clone)] -struct ChannelField { - key: &'static str, - label: &'static str, - field_type: FieldType, - env_var: Option<&'static str>, - required: bool, - placeholder: &'static str, - /// If true, this field is hidden under "Show Advanced" in the UI. - advanced: bool, -} - -/// Metadata for one channel adapter. -struct ChannelMeta { - name: &'static str, - display_name: &'static str, - icon: &'static str, - description: &'static str, - category: &'static str, - difficulty: &'static str, - setup_time: &'static str, - /// One-line quick setup hint shown in the simple form view. - quick_setup: &'static str, - /// Setup type: "form" (default), "qr" (QR code scan + form fallback). - setup_type: &'static str, - fields: &'static [ChannelField], - setup_steps: &'static [&'static str], - config_template: &'static str, -} - -const CHANNEL_REGISTRY: &[ChannelMeta] = &[ - // ── Messaging (12) ────────────────────────────────────────────── - ChannelMeta { - name: "telegram", display_name: "Telegram", icon: "TG", - description: "Telegram Bot API — long-polling adapter", - category: "messaging", difficulty: "Easy", setup_time: "~2 min", - quick_setup: "Paste your bot token from @BotFather", - setup_type: "form", - fields: &[ - ChannelField { key: "bot_token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("TELEGRAM_BOT_TOKEN"), required: true, placeholder: "123456:ABC-DEF...", advanced: false }, - ChannelField { key: "allowed_users", label: "Allowed User IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "12345, 67890", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ChannelField { key: "poll_interval_secs", label: "Poll Interval (sec)", field_type: FieldType::Number, env_var: None, required: false, placeholder: "1", advanced: true }, - ], - setup_steps: &["Open @BotFather on Telegram", "Send /newbot and follow the prompts", "Paste the token below"], - config_template: "[channels.telegram]\nbot_token_env = \"TELEGRAM_BOT_TOKEN\"", - }, - ChannelMeta { - name: "discord", display_name: "Discord", icon: "DC", - description: "Discord Gateway bot adapter", - category: "messaging", difficulty: "Easy", setup_time: "~3 min", - quick_setup: "Paste your bot token from the Discord Developer Portal", - setup_type: "form", - fields: &[ - ChannelField { key: "bot_token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("DISCORD_BOT_TOKEN"), required: true, placeholder: "MTIz...", advanced: false }, - ChannelField { key: "allowed_guilds", label: "Allowed Guild IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "123456789, 987654321", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ChannelField { key: "intents", label: "Intents Bitmask", field_type: FieldType::Number, env_var: None, required: false, placeholder: "37376", advanced: true }, - ], - setup_steps: &["Go to discord.com/developers/applications", "Create a bot and copy the token", "Paste it below"], - config_template: "[channels.discord]\nbot_token_env = \"DISCORD_BOT_TOKEN\"", - }, - ChannelMeta { - name: "slack", display_name: "Slack", icon: "SL", - description: "Slack Socket Mode + Events API", - category: "messaging", difficulty: "Medium", setup_time: "~5 min", - quick_setup: "Paste your App Token and Bot Token from api.slack.com", - setup_type: "form", - fields: &[ - ChannelField { key: "app_token_env", label: "App Token (xapp-)", field_type: FieldType::Secret, env_var: Some("SLACK_APP_TOKEN"), required: true, placeholder: "xapp-1-...", advanced: false }, - ChannelField { key: "bot_token_env", label: "Bot Token (xoxb-)", field_type: FieldType::Secret, env_var: Some("SLACK_BOT_TOKEN"), required: true, placeholder: "xoxb-...", advanced: false }, - ChannelField { key: "allowed_channels", label: "Allowed Channel IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "C01234, C56789", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Create app at api.slack.com/apps", "Enable Socket Mode and copy App Token", "Copy Bot Token from OAuth & Permissions"], - config_template: "[channels.slack]\napp_token_env = \"SLACK_APP_TOKEN\"\nbot_token_env = \"SLACK_BOT_TOKEN\"", - }, - ChannelMeta { - name: "whatsapp", display_name: "WhatsApp", icon: "WA", - description: "Connect your personal WhatsApp via QR scan", - category: "messaging", difficulty: "Easy", setup_time: "~1 min", - quick_setup: "Scan QR code with your phone — no developer account needed", - setup_type: "qr", - fields: &[ - // Business API fallback fields — all advanced (hidden behind "Use Business API" toggle) - ChannelField { key: "access_token_env", label: "Access Token", field_type: FieldType::Secret, env_var: Some("WHATSAPP_ACCESS_TOKEN"), required: false, placeholder: "EAAx...", advanced: true }, - ChannelField { key: "phone_number_id", label: "Phone Number ID", field_type: FieldType::Text, env_var: None, required: false, placeholder: "1234567890", advanced: true }, - ChannelField { key: "verify_token_env", label: "Verify Token", field_type: FieldType::Secret, env_var: Some("WHATSAPP_VERIFY_TOKEN"), required: false, placeholder: "my-verify-token", advanced: true }, - ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8443", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Open WhatsApp on your phone", "Go to Linked Devices", "Tap Link a Device and scan the QR code"], - config_template: "[channels.whatsapp]\naccess_token_env = \"WHATSAPP_ACCESS_TOKEN\"\nphone_number_id = \"\"", - }, - ChannelMeta { - name: "signal", display_name: "Signal", icon: "SG", - description: "Signal via signal-cli REST API", - category: "messaging", difficulty: "Medium", setup_time: "~10 min", - quick_setup: "Enter your signal-cli API URL", - setup_type: "form", - fields: &[ - ChannelField { key: "api_url", label: "signal-cli API URL", field_type: FieldType::Text, env_var: None, required: true, placeholder: "http://localhost:8080", advanced: false }, - ChannelField { key: "phone_number", label: "Phone Number", field_type: FieldType::Text, env_var: None, required: true, placeholder: "+1234567890", advanced: false }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Install signal-cli-rest-api", "Enter the API URL and your phone number"], - config_template: "[channels.signal]\napi_url = \"http://localhost:8080\"\nphone_number = \"\"", - }, - ChannelMeta { - name: "matrix", display_name: "Matrix", icon: "MX", - description: "Matrix/Element bot via homeserver", - category: "messaging", difficulty: "Easy", setup_time: "~3 min", - quick_setup: "Paste your access token and homeserver URL", - setup_type: "form", - fields: &[ - ChannelField { key: "access_token_env", label: "Access Token", field_type: FieldType::Secret, env_var: Some("MATRIX_ACCESS_TOKEN"), required: true, placeholder: "syt_...", advanced: false }, - ChannelField { key: "homeserver_url", label: "Homeserver URL", field_type: FieldType::Text, env_var: None, required: true, placeholder: "https://matrix.org", advanced: false }, - ChannelField { key: "user_id", label: "Bot User ID", field_type: FieldType::Text, env_var: None, required: false, placeholder: "@openfang:matrix.org", advanced: true }, - ChannelField { key: "allowed_rooms", label: "Allowed Room IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "!abc:matrix.org", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Create a bot account on your homeserver", "Generate an access token", "Paste token and homeserver URL below"], - config_template: "[channels.matrix]\naccess_token_env = \"MATRIX_ACCESS_TOKEN\"\nhomeserver_url = \"https://matrix.org\"", - }, - ChannelMeta { - name: "email", display_name: "Email", icon: "EM", - description: "IMAP/SMTP email adapter", - category: "messaging", difficulty: "Easy", setup_time: "~3 min", - quick_setup: "Enter your email, password, and server hosts", - setup_type: "form", - fields: &[ - ChannelField { key: "username", label: "Email Address", field_type: FieldType::Text, env_var: None, required: true, placeholder: "bot@example.com", advanced: false }, - ChannelField { key: "password_env", label: "Password / App Password", field_type: FieldType::Secret, env_var: Some("EMAIL_PASSWORD"), required: true, placeholder: "app-password", advanced: false }, - ChannelField { key: "imap_host", label: "IMAP Host", field_type: FieldType::Text, env_var: None, required: true, placeholder: "imap.gmail.com", advanced: false }, - ChannelField { key: "smtp_host", label: "SMTP Host", field_type: FieldType::Text, env_var: None, required: true, placeholder: "smtp.gmail.com", advanced: false }, - ChannelField { key: "imap_port", label: "IMAP Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "993", advanced: true }, - ChannelField { key: "smtp_port", label: "SMTP Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "587", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Enable IMAP on your email account", "Generate an app password if using Gmail", "Fill in email, password, and hosts below"], - config_template: "[channels.email]\nimap_host = \"imap.gmail.com\"\nsmtp_host = \"smtp.gmail.com\"\npassword_env = \"EMAIL_PASSWORD\"", - }, - ChannelMeta { - name: "line", display_name: "LINE", icon: "LN", - description: "LINE Messaging API adapter", - category: "messaging", difficulty: "Easy", setup_time: "~3 min", - quick_setup: "Paste your Channel Secret and Access Token", - setup_type: "form", - fields: &[ - ChannelField { key: "channel_secret_env", label: "Channel Secret", field_type: FieldType::Secret, env_var: Some("LINE_CHANNEL_SECRET"), required: true, placeholder: "abc123...", advanced: false }, - ChannelField { key: "access_token_env", label: "Channel Access Token", field_type: FieldType::Secret, env_var: Some("LINE_CHANNEL_ACCESS_TOKEN"), required: true, placeholder: "xyz789...", advanced: false }, - ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8450", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Create a Messaging API channel at LINE Developers", "Copy Channel Secret and Access Token", "Paste them below"], - config_template: "[channels.line]\nchannel_secret_env = \"LINE_CHANNEL_SECRET\"\naccess_token_env = \"LINE_CHANNEL_ACCESS_TOKEN\"", - }, - ChannelMeta { - name: "viber", display_name: "Viber", icon: "VB", - description: "Viber Bot API adapter", - category: "messaging", difficulty: "Easy", setup_time: "~2 min", - quick_setup: "Paste your auth token from partners.viber.com", - setup_type: "form", - fields: &[ - ChannelField { key: "auth_token_env", label: "Auth Token", field_type: FieldType::Secret, env_var: Some("VIBER_AUTH_TOKEN"), required: true, placeholder: "4dc...", advanced: false }, - ChannelField { key: "webhook_url", label: "Webhook URL", field_type: FieldType::Text, env_var: None, required: false, placeholder: "https://your-domain.com/viber", advanced: true }, - ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8451", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Create a bot at partners.viber.com", "Copy the auth token", "Paste it below"], - config_template: "[channels.viber]\nauth_token_env = \"VIBER_AUTH_TOKEN\"", - }, - ChannelMeta { - name: "messenger", display_name: "Messenger", icon: "FB", - description: "Facebook Messenger Platform adapter", - category: "messaging", difficulty: "Medium", setup_time: "~10 min", - quick_setup: "Paste your Page Access Token from developers.facebook.com", - setup_type: "form", - fields: &[ - ChannelField { key: "page_token_env", label: "Page Access Token", field_type: FieldType::Secret, env_var: Some("MESSENGER_PAGE_TOKEN"), required: true, placeholder: "EAAx...", advanced: false }, - ChannelField { key: "verify_token_env", label: "Verify Token", field_type: FieldType::Secret, env_var: Some("MESSENGER_VERIFY_TOKEN"), required: false, placeholder: "my-verify-token", advanced: true }, - ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8452", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Create a Facebook App and add Messenger", "Generate a Page Access Token", "Paste it below"], - config_template: "[channels.messenger]\npage_token_env = \"MESSENGER_PAGE_TOKEN\"", - }, - ChannelMeta { - name: "threema", display_name: "Threema", icon: "3M", - description: "Threema Gateway adapter", - category: "messaging", difficulty: "Easy", setup_time: "~3 min", - quick_setup: "Paste your Gateway ID and API secret", - setup_type: "form", - fields: &[ - ChannelField { key: "secret_env", label: "API Secret", field_type: FieldType::Secret, env_var: Some("THREEMA_SECRET"), required: true, placeholder: "abc123...", advanced: false }, - ChannelField { key: "threema_id", label: "Gateway ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "*MYID01", advanced: false }, - ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8454", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Register at gateway.threema.ch", "Copy your ID and API secret", "Paste them below"], - config_template: "[channels.threema]\nthreema_id = \"\"\nsecret_env = \"THREEMA_SECRET\"", - }, - ChannelMeta { - name: "keybase", display_name: "Keybase", icon: "KB", - description: "Keybase chat bot adapter", - category: "messaging", difficulty: "Easy", setup_time: "~3 min", - quick_setup: "Enter your username and paper key", - setup_type: "form", - fields: &[ - ChannelField { key: "username", label: "Username", field_type: FieldType::Text, env_var: None, required: true, placeholder: "openfang_bot", advanced: false }, - ChannelField { key: "paperkey_env", label: "Paper Key", field_type: FieldType::Secret, env_var: Some("KEYBASE_PAPERKEY"), required: true, placeholder: "word1 word2 word3...", advanced: false }, - ChannelField { key: "allowed_teams", label: "Allowed Teams", field_type: FieldType::List, env_var: None, required: false, placeholder: "team1, team2", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Create a Keybase bot account", "Generate a paper key", "Enter username and paper key below"], - config_template: "[channels.keybase]\nusername = \"\"\npaperkey_env = \"KEYBASE_PAPERKEY\"", - }, - // ── Social (5) ────────────────────────────────────────────────── - ChannelMeta { - name: "reddit", display_name: "Reddit", icon: "RD", - description: "Reddit API bot adapter", - category: "social", difficulty: "Medium", setup_time: "~5 min", - quick_setup: "Paste your Client ID, Secret, and bot credentials", - setup_type: "form", - fields: &[ - ChannelField { key: "client_id", label: "Client ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "abc123def", advanced: false }, - ChannelField { key: "client_secret_env", label: "Client Secret", field_type: FieldType::Secret, env_var: Some("REDDIT_CLIENT_SECRET"), required: true, placeholder: "abc123...", advanced: false }, - ChannelField { key: "username", label: "Bot Username", field_type: FieldType::Text, env_var: None, required: true, placeholder: "openfang_bot", advanced: false }, - ChannelField { key: "password_env", label: "Bot Password", field_type: FieldType::Secret, env_var: Some("REDDIT_PASSWORD"), required: true, placeholder: "password", advanced: false }, - ChannelField { key: "subreddits", label: "Subreddits", field_type: FieldType::List, env_var: None, required: false, placeholder: "openfang, rust", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Create a Reddit app at reddit.com/prefs/apps (script type)", "Copy Client ID and Secret", "Enter bot credentials below"], - config_template: "[channels.reddit]\nclient_id = \"\"\nclient_secret_env = \"REDDIT_CLIENT_SECRET\"\nusername = \"\"\npassword_env = \"REDDIT_PASSWORD\"", - }, - ChannelMeta { - name: "mastodon", display_name: "Mastodon", icon: "MA", - description: "Mastodon Streaming API adapter", - category: "social", difficulty: "Easy", setup_time: "~2 min", - quick_setup: "Paste your access token from Settings > Development", - setup_type: "form", - fields: &[ - ChannelField { key: "access_token_env", label: "Access Token", field_type: FieldType::Secret, env_var: Some("MASTODON_ACCESS_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, - ChannelField { key: "instance_url", label: "Instance URL", field_type: FieldType::Text, env_var: None, required: true, placeholder: "https://mastodon.social", advanced: false }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Go to Settings > Development on your instance", "Create an app and copy the token", "Paste it below"], - config_template: "[channels.mastodon]\ninstance_url = \"https://mastodon.social\"\naccess_token_env = \"MASTODON_ACCESS_TOKEN\"", - }, - ChannelMeta { - name: "bluesky", display_name: "Bluesky", icon: "BS", - description: "Bluesky/AT Protocol adapter", - category: "social", difficulty: "Easy", setup_time: "~1 min", - quick_setup: "Enter your handle and app password", - setup_type: "form", - fields: &[ - ChannelField { key: "identifier", label: "Handle", field_type: FieldType::Text, env_var: None, required: true, placeholder: "user.bsky.social", advanced: false }, - ChannelField { key: "app_password_env", label: "App Password", field_type: FieldType::Secret, env_var: Some("BLUESKY_APP_PASSWORD"), required: true, placeholder: "xxxx-xxxx-xxxx-xxxx", advanced: false }, - ChannelField { key: "service_url", label: "PDS URL", field_type: FieldType::Text, env_var: None, required: false, placeholder: "https://bsky.social", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Go to Settings > App Passwords in Bluesky", "Create an app password", "Enter handle and password below"], - config_template: "[channels.bluesky]\nidentifier = \"\"\napp_password_env = \"BLUESKY_APP_PASSWORD\"", - }, - ChannelMeta { - name: "linkedin", display_name: "LinkedIn", icon: "LI", - description: "LinkedIn Messaging API adapter", - category: "social", difficulty: "Hard", setup_time: "~15 min", - quick_setup: "Paste your OAuth2 access token and Organization ID", - setup_type: "form", - fields: &[ - ChannelField { key: "access_token_env", label: "Access Token", field_type: FieldType::Secret, env_var: Some("LINKEDIN_ACCESS_TOKEN"), required: true, placeholder: "AQV...", advanced: false }, - ChannelField { key: "organization_id", label: "Organization ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "12345678", advanced: false }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Create a LinkedIn App at linkedin.com/developers", "Generate an OAuth2 token", "Enter token and org ID below"], - config_template: "[channels.linkedin]\naccess_token_env = \"LINKEDIN_ACCESS_TOKEN\"\norganization_id = \"\"", - }, - ChannelMeta { - name: "nostr", display_name: "Nostr", icon: "NS", - description: "Nostr relay protocol adapter", - category: "social", difficulty: "Easy", setup_time: "~2 min", - quick_setup: "Paste your private key (nsec or hex)", - setup_type: "form", - fields: &[ - ChannelField { key: "private_key_env", label: "Private Key", field_type: FieldType::Secret, env_var: Some("NOSTR_PRIVATE_KEY"), required: true, placeholder: "nsec1...", advanced: false }, - ChannelField { key: "relays", label: "Relay URLs", field_type: FieldType::List, env_var: None, required: false, placeholder: "wss://relay.damus.io", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Generate or use an existing Nostr keypair", "Paste your private key below"], - config_template: "[channels.nostr]\nprivate_key_env = \"NOSTR_PRIVATE_KEY\"", - }, - // ── Enterprise (10) ───────────────────────────────────────────── - ChannelMeta { - name: "teams", display_name: "Microsoft Teams", icon: "MS", - description: "Teams Bot Framework adapter", - category: "enterprise", difficulty: "Medium", setup_time: "~10 min", - quick_setup: "Paste your Azure Bot App ID and Password", - setup_type: "form", - fields: &[ - ChannelField { key: "app_id", label: "App ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "00000000-0000-...", advanced: false }, - ChannelField { key: "app_password_env", label: "App Password", field_type: FieldType::Secret, env_var: Some("TEAMS_APP_PASSWORD"), required: true, placeholder: "abc123...", advanced: false }, - ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "3978", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Create an Azure Bot registration", "Copy App ID and generate a password", "Paste them below"], - config_template: "[channels.teams]\napp_id = \"\"\napp_password_env = \"TEAMS_APP_PASSWORD\"", - }, - ChannelMeta { - name: "mattermost", display_name: "Mattermost", icon: "MM", - description: "Mattermost WebSocket adapter", - category: "enterprise", difficulty: "Easy", setup_time: "~2 min", - quick_setup: "Paste your bot token and server URL", - setup_type: "form", - fields: &[ - ChannelField { key: "server_url", label: "Server URL", field_type: FieldType::Text, env_var: None, required: true, placeholder: "https://mattermost.example.com", advanced: false }, - ChannelField { key: "token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("MATTERMOST_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, - ChannelField { key: "allowed_channels", label: "Allowed Channels", field_type: FieldType::List, env_var: None, required: false, placeholder: "abc123, def456", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Create a bot in System Console > Bot Accounts", "Copy the token", "Enter server URL and token below"], - config_template: "[channels.mattermost]\nserver_url = \"\"\ntoken_env = \"MATTERMOST_TOKEN\"", - }, - ChannelMeta { - name: "google_chat", display_name: "Google Chat", icon: "GC", - description: "Google Chat service account adapter", - category: "enterprise", difficulty: "Hard", setup_time: "~15 min", - quick_setup: "Enter path to your service account JSON key", - setup_type: "form", - fields: &[ - ChannelField { key: "service_account_env", label: "Service Account JSON", field_type: FieldType::Secret, env_var: Some("GOOGLE_CHAT_SERVICE_ACCOUNT"), required: true, placeholder: "/path/to/key.json", advanced: false }, - ChannelField { key: "space_ids", label: "Space IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "spaces/AAAA", advanced: true }, - ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8444", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Create a Google Cloud project with Chat API", "Download service account JSON key", "Enter the path below"], - config_template: "[channels.google_chat]\nservice_account_env = \"GOOGLE_CHAT_SERVICE_ACCOUNT\"", - }, - ChannelMeta { - name: "webex", display_name: "Webex", icon: "WX", - description: "Cisco Webex bot adapter", - category: "enterprise", difficulty: "Easy", setup_time: "~2 min", - quick_setup: "Paste your bot token from developer.webex.com", - setup_type: "form", - fields: &[ - ChannelField { key: "bot_token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("WEBEX_BOT_TOKEN"), required: true, placeholder: "NjI...", advanced: false }, - ChannelField { key: "allowed_rooms", label: "Allowed Rooms", field_type: FieldType::List, env_var: None, required: false, placeholder: "Y2lz...", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Create a bot at developer.webex.com", "Copy the token", "Paste it below"], - config_template: "[channels.webex]\nbot_token_env = \"WEBEX_BOT_TOKEN\"", - }, - ChannelMeta { - name: "feishu", display_name: "Feishu/Lark", icon: "FS", - description: "Feishu/Lark Open Platform adapter", - category: "enterprise", difficulty: "Easy", setup_time: "~3 min", - quick_setup: "Paste your App ID and App Secret", - setup_type: "form", - fields: &[ - ChannelField { key: "app_id", label: "App ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "cli_abc123", advanced: false }, - ChannelField { key: "app_secret_env", label: "App Secret", field_type: FieldType::Secret, env_var: Some("FEISHU_APP_SECRET"), required: true, placeholder: "abc123...", advanced: false }, - ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8453", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Create an app at open.feishu.cn", "Copy App ID and Secret", "Paste them below"], - config_template: "[channels.feishu]\napp_id = \"\"\napp_secret_env = \"FEISHU_APP_SECRET\"", - }, - ChannelMeta { - name: "dingtalk", display_name: "DingTalk", icon: "DT", - description: "DingTalk Robot API adapter", - category: "enterprise", difficulty: "Easy", setup_time: "~3 min", - quick_setup: "Paste your webhook token and signing secret", - setup_type: "form", - fields: &[ - ChannelField { key: "access_token_env", label: "Access Token", field_type: FieldType::Secret, env_var: Some("DINGTALK_ACCESS_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, - ChannelField { key: "secret_env", label: "Signing Secret", field_type: FieldType::Secret, env_var: Some("DINGTALK_SECRET"), required: true, placeholder: "SEC...", advanced: false }, - ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8457", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Create a robot in your DingTalk group", "Copy the token and signing secret", "Paste them below"], - config_template: "[channels.dingtalk]\naccess_token_env = \"DINGTALK_ACCESS_TOKEN\"\nsecret_env = \"DINGTALK_SECRET\"", - }, - ChannelMeta { - name: "pumble", display_name: "Pumble", icon: "PB", - description: "Pumble bot adapter", - category: "enterprise", difficulty: "Easy", setup_time: "~1 min", - quick_setup: "Paste your bot token", - setup_type: "form", - fields: &[ - ChannelField { key: "bot_token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("PUMBLE_BOT_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, - ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8455", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Create a bot in Pumble Integrations", "Copy the token", "Paste it below"], - config_template: "[channels.pumble]\nbot_token_env = \"PUMBLE_BOT_TOKEN\"", - }, - ChannelMeta { - name: "flock", display_name: "Flock", icon: "FL", - description: "Flock bot adapter", - category: "enterprise", difficulty: "Easy", setup_time: "~1 min", - quick_setup: "Paste your bot token", - setup_type: "form", - fields: &[ - ChannelField { key: "bot_token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("FLOCK_BOT_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, - ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8456", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Build an app in Flock App Store", "Copy the bot token", "Paste it below"], - config_template: "[channels.flock]\nbot_token_env = \"FLOCK_BOT_TOKEN\"", - }, - ChannelMeta { - name: "twist", display_name: "Twist", icon: "TW", - description: "Twist API v3 adapter", - category: "enterprise", difficulty: "Easy", setup_time: "~2 min", - quick_setup: "Paste your API token and workspace ID", - setup_type: "form", - fields: &[ - ChannelField { key: "token_env", label: "API Token", field_type: FieldType::Secret, env_var: Some("TWIST_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, - ChannelField { key: "workspace_id", label: "Workspace ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "12345", advanced: false }, - ChannelField { key: "allowed_channels", label: "Channel IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "123, 456", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Create an integration in Twist Settings", "Copy the API token", "Enter token and workspace ID below"], - config_template: "[channels.twist]\ntoken_env = \"TWIST_TOKEN\"\nworkspace_id = \"\"", - }, - ChannelMeta { - name: "zulip", display_name: "Zulip", icon: "ZL", - description: "Zulip event queue adapter", - category: "enterprise", difficulty: "Easy", setup_time: "~2 min", - quick_setup: "Paste your API key, server URL, and bot email", - setup_type: "form", - fields: &[ - ChannelField { key: "server_url", label: "Server URL", field_type: FieldType::Text, env_var: None, required: true, placeholder: "https://chat.zulip.org", advanced: false }, - ChannelField { key: "bot_email", label: "Bot Email", field_type: FieldType::Text, env_var: None, required: true, placeholder: "bot@zulip.example.com", advanced: false }, - ChannelField { key: "api_key_env", label: "API Key", field_type: FieldType::Secret, env_var: Some("ZULIP_API_KEY"), required: true, placeholder: "abc123...", advanced: false }, - ChannelField { key: "streams", label: "Streams", field_type: FieldType::List, env_var: None, required: false, placeholder: "general, dev", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Create a bot in Zulip Settings > Your Bots", "Copy the API key", "Enter server URL, bot email, and key below"], - config_template: "[channels.zulip]\nserver_url = \"\"\nbot_email = \"\"\napi_key_env = \"ZULIP_API_KEY\"", - }, - // ── Developer (9) ─────────────────────────────────────────────── - ChannelMeta { - name: "irc", display_name: "IRC", icon: "IR", - description: "IRC raw TCP adapter", - category: "developer", difficulty: "Easy", setup_time: "~2 min", - quick_setup: "Enter server and nickname", - setup_type: "form", - fields: &[ - ChannelField { key: "server", label: "Server", field_type: FieldType::Text, env_var: None, required: true, placeholder: "irc.libera.chat", advanced: false }, - ChannelField { key: "nick", label: "Nickname", field_type: FieldType::Text, env_var: None, required: true, placeholder: "openfang", advanced: false }, - ChannelField { key: "channels", label: "Channels", field_type: FieldType::List, env_var: None, required: false, placeholder: "#openfang, #general", advanced: false }, - ChannelField { key: "port", label: "Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "6667", advanced: true }, - ChannelField { key: "use_tls", label: "Use TLS", field_type: FieldType::Text, env_var: None, required: false, placeholder: "false", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Choose an IRC server", "Enter server, nick, and channels below"], - config_template: "[channels.irc]\nserver = \"irc.libera.chat\"\nnick = \"openfang\"", - }, - ChannelMeta { - name: "xmpp", display_name: "XMPP/Jabber", icon: "XM", - description: "XMPP/Jabber protocol adapter", - category: "developer", difficulty: "Easy", setup_time: "~3 min", - quick_setup: "Enter your JID and password", - setup_type: "form", - fields: &[ - ChannelField { key: "jid", label: "JID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "bot@jabber.org", advanced: false }, - ChannelField { key: "password_env", label: "Password", field_type: FieldType::Secret, env_var: Some("XMPP_PASSWORD"), required: true, placeholder: "password", advanced: false }, - ChannelField { key: "server", label: "Server", field_type: FieldType::Text, env_var: None, required: false, placeholder: "jabber.org", advanced: true }, - ChannelField { key: "port", label: "Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "5222", advanced: true }, - ChannelField { key: "rooms", label: "MUC Rooms", field_type: FieldType::List, env_var: None, required: false, placeholder: "room@conference.jabber.org", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Create a bot account on your XMPP server", "Enter JID and password below"], - config_template: "[channels.xmpp]\njid = \"\"\npassword_env = \"XMPP_PASSWORD\"", - }, - ChannelMeta { - name: "gitter", display_name: "Gitter", icon: "GT", - description: "Gitter Streaming API adapter", - category: "developer", difficulty: "Easy", setup_time: "~2 min", - quick_setup: "Paste your auth token and room ID", - setup_type: "form", - fields: &[ - ChannelField { key: "token_env", label: "Auth Token", field_type: FieldType::Secret, env_var: Some("GITTER_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, - ChannelField { key: "room_id", label: "Room ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "abc123def456", advanced: false }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Get a token from developer.gitter.im", "Find your room ID", "Paste both below"], - config_template: "[channels.gitter]\ntoken_env = \"GITTER_TOKEN\"\nroom_id = \"\"", - }, - ChannelMeta { - name: "discourse", display_name: "Discourse", icon: "DS", - description: "Discourse forum API adapter", - category: "developer", difficulty: "Easy", setup_time: "~2 min", - quick_setup: "Paste your API key and forum URL", - setup_type: "form", - fields: &[ - ChannelField { key: "base_url", label: "Forum URL", field_type: FieldType::Text, env_var: None, required: true, placeholder: "https://forum.example.com", advanced: false }, - ChannelField { key: "api_key_env", label: "API Key", field_type: FieldType::Secret, env_var: Some("DISCOURSE_API_KEY"), required: true, placeholder: "abc123...", advanced: false }, - ChannelField { key: "api_username", label: "API Username", field_type: FieldType::Text, env_var: None, required: false, placeholder: "system", advanced: true }, - ChannelField { key: "categories", label: "Categories", field_type: FieldType::List, env_var: None, required: false, placeholder: "general, support", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Go to Admin > API > Keys", "Generate an API key", "Enter forum URL and key below"], - config_template: "[channels.discourse]\nbase_url = \"\"\napi_key_env = \"DISCOURSE_API_KEY\"", - }, - ChannelMeta { - name: "revolt", display_name: "Revolt", icon: "RV", - description: "Revolt bot adapter", - category: "developer", difficulty: "Easy", setup_time: "~1 min", - quick_setup: "Paste your bot token", - setup_type: "form", - fields: &[ - ChannelField { key: "bot_token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("REVOLT_BOT_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, - ChannelField { key: "api_url", label: "API URL", field_type: FieldType::Text, env_var: None, required: false, placeholder: "https://api.revolt.chat", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Go to Settings > My Bots in Revolt", "Create a bot and copy the token", "Paste it below"], - config_template: "[channels.revolt]\nbot_token_env = \"REVOLT_BOT_TOKEN\"", - }, - ChannelMeta { - name: "guilded", display_name: "Guilded", icon: "GD", - description: "Guilded bot adapter", - category: "developer", difficulty: "Easy", setup_time: "~1 min", - quick_setup: "Paste your bot token", - setup_type: "form", - fields: &[ - ChannelField { key: "bot_token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("GUILDED_BOT_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, - ChannelField { key: "server_ids", label: "Server IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "abc123", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Go to Server Settings > Bots in Guilded", "Create a bot and copy the token", "Paste it below"], - config_template: "[channels.guilded]\nbot_token_env = \"GUILDED_BOT_TOKEN\"", - }, - ChannelMeta { - name: "nextcloud", display_name: "Nextcloud Talk", icon: "NC", - description: "Nextcloud Talk REST adapter", - category: "developer", difficulty: "Easy", setup_time: "~2 min", - quick_setup: "Paste your server URL and auth token", - setup_type: "form", - fields: &[ - ChannelField { key: "server_url", label: "Server URL", field_type: FieldType::Text, env_var: None, required: true, placeholder: "https://cloud.example.com", advanced: false }, - ChannelField { key: "token_env", label: "Auth Token", field_type: FieldType::Secret, env_var: Some("NEXTCLOUD_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, - ChannelField { key: "allowed_rooms", label: "Room Tokens", field_type: FieldType::List, env_var: None, required: false, placeholder: "abc123", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Create a bot user in Nextcloud", "Generate an app password", "Enter URL and token below"], - config_template: "[channels.nextcloud]\nserver_url = \"\"\ntoken_env = \"NEXTCLOUD_TOKEN\"", - }, - ChannelMeta { - name: "rocketchat", display_name: "Rocket.Chat", icon: "RC", - description: "Rocket.Chat REST adapter", - category: "developer", difficulty: "Easy", setup_time: "~2 min", - quick_setup: "Paste your server URL, user ID, and token", - setup_type: "form", - fields: &[ - ChannelField { key: "server_url", label: "Server URL", field_type: FieldType::Text, env_var: None, required: true, placeholder: "https://rocket.example.com", advanced: false }, - ChannelField { key: "user_id", label: "Bot User ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "abc123", advanced: false }, - ChannelField { key: "token_env", label: "Auth Token", field_type: FieldType::Secret, env_var: Some("ROCKETCHAT_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, - ChannelField { key: "allowed_channels", label: "Channel IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "GENERAL", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Create a bot in Admin > Users", "Generate a personal access token", "Enter URL, user ID, and token below"], - config_template: "[channels.rocketchat]\nserver_url = \"\"\ntoken_env = \"ROCKETCHAT_TOKEN\"\nuser_id = \"\"", - }, - ChannelMeta { - name: "twitch", display_name: "Twitch", icon: "TV", - description: "Twitch IRC gateway adapter", - category: "developer", difficulty: "Easy", setup_time: "~2 min", - quick_setup: "Paste your OAuth token and enter channel name", - setup_type: "form", - fields: &[ - ChannelField { key: "oauth_token_env", label: "OAuth Token", field_type: FieldType::Secret, env_var: Some("TWITCH_OAUTH_TOKEN"), required: true, placeholder: "oauth:abc123...", advanced: false }, - ChannelField { key: "nick", label: "Bot Nickname", field_type: FieldType::Text, env_var: None, required: true, placeholder: "openfang", advanced: false }, - ChannelField { key: "channels", label: "Channels (no #)", field_type: FieldType::List, env_var: None, required: true, placeholder: "mychannel", advanced: false }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Generate an OAuth token at twitchapps.com/tmi", "Enter token, nick, and channel below"], - config_template: "[channels.twitch]\noauth_token_env = \"TWITCH_OAUTH_TOKEN\"\nnick = \"openfang\"", - }, - // ── Notifications (4) ─────────────────────────────────────────── - ChannelMeta { - name: "ntfy", display_name: "ntfy", icon: "NF", - description: "ntfy.sh pub/sub notification adapter", - category: "notifications", difficulty: "Easy", setup_time: "~1 min", - quick_setup: "Just enter a topic name", - setup_type: "form", - fields: &[ - ChannelField { key: "topic", label: "Topic", field_type: FieldType::Text, env_var: None, required: true, placeholder: "openfang-alerts", advanced: false }, - ChannelField { key: "server_url", label: "Server URL", field_type: FieldType::Text, env_var: None, required: false, placeholder: "https://ntfy.sh", advanced: true }, - ChannelField { key: "token_env", label: "Auth Token", field_type: FieldType::Secret, env_var: Some("NTFY_TOKEN"), required: false, placeholder: "tk_abc123...", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Pick a topic name", "Enter it below — that's it!"], - config_template: "[channels.ntfy]\ntopic = \"\"", - }, - ChannelMeta { - name: "gotify", display_name: "Gotify", icon: "GF", - description: "Gotify WebSocket notification adapter", - category: "notifications", difficulty: "Easy", setup_time: "~2 min", - quick_setup: "Paste your server URL and tokens", - setup_type: "form", - fields: &[ - ChannelField { key: "server_url", label: "Server URL", field_type: FieldType::Text, env_var: None, required: true, placeholder: "https://gotify.example.com", advanced: false }, - ChannelField { key: "app_token_env", label: "App Token (send)", field_type: FieldType::Secret, env_var: Some("GOTIFY_APP_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, - ChannelField { key: "client_token_env", label: "Client Token (receive)", field_type: FieldType::Secret, env_var: Some("GOTIFY_CLIENT_TOKEN"), required: true, placeholder: "def456...", advanced: false }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Create an app and a client in Gotify", "Copy both tokens", "Enter URL and tokens below"], - config_template: "[channels.gotify]\nserver_url = \"\"\napp_token_env = \"GOTIFY_APP_TOKEN\"\nclient_token_env = \"GOTIFY_CLIENT_TOKEN\"", - }, - ChannelMeta { - name: "webhook", display_name: "Webhook", icon: "WH", - description: "Generic HMAC-signed webhook adapter", - category: "notifications", difficulty: "Easy", setup_time: "~1 min", - quick_setup: "Optionally set an HMAC secret", - setup_type: "form", - fields: &[ - ChannelField { key: "secret_env", label: "HMAC Secret", field_type: FieldType::Secret, env_var: Some("WEBHOOK_SECRET"), required: false, placeholder: "my-secret", advanced: false }, - ChannelField { key: "listen_port", label: "Listen Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8460", advanced: true }, - ChannelField { key: "callback_url", label: "Callback URL", field_type: FieldType::Text, env_var: None, required: false, placeholder: "https://example.com/webhook", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Enter an HMAC secret (or leave blank)", "Click Save — that's it!"], - config_template: "[channels.webhook]\nsecret_env = \"WEBHOOK_SECRET\"", - }, - ChannelMeta { - name: "mumble", display_name: "Mumble", icon: "MB", - description: "Mumble text chat adapter", - category: "notifications", difficulty: "Easy", setup_time: "~2 min", - quick_setup: "Enter server host and username", - setup_type: "form", - fields: &[ - ChannelField { key: "host", label: "Host", field_type: FieldType::Text, env_var: None, required: true, placeholder: "mumble.example.com", advanced: false }, - ChannelField { key: "username", label: "Username", field_type: FieldType::Text, env_var: None, required: true, placeholder: "openfang", advanced: false }, - ChannelField { key: "password_env", label: "Server Password", field_type: FieldType::Secret, env_var: Some("MUMBLE_PASSWORD"), required: false, placeholder: "password", advanced: true }, - ChannelField { key: "port", label: "Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "64738", advanced: true }, - ChannelField { key: "channel", label: "Channel", field_type: FieldType::Text, env_var: None, required: false, placeholder: "Root", advanced: true }, - ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ], - setup_steps: &["Enter host and username below", "Optionally add a password"], - config_template: "[channels.mumble]\nhost = \"\"\nusername = \"openfang\"", - }, -]; - -/// Check if a channel is configured (has a `[channels.xxx]` section in config). -fn is_channel_configured(config: &openfang_types::config::ChannelsConfig, name: &str) -> bool { - match name { - "telegram" => config.telegram.is_some(), - "discord" => config.discord.is_some(), - "slack" => config.slack.is_some(), - "whatsapp" => config.whatsapp.is_some(), - "signal" => config.signal.is_some(), - "matrix" => config.matrix.is_some(), - "email" => config.email.is_some(), - "line" => config.line.is_some(), - "viber" => config.viber.is_some(), - "messenger" => config.messenger.is_some(), - "threema" => config.threema.is_some(), - "keybase" => config.keybase.is_some(), - "reddit" => config.reddit.is_some(), - "mastodon" => config.mastodon.is_some(), - "bluesky" => config.bluesky.is_some(), - "linkedin" => config.linkedin.is_some(), - "nostr" => config.nostr.is_some(), - "teams" => config.teams.is_some(), - "mattermost" => config.mattermost.is_some(), - "google_chat" => config.google_chat.is_some(), - "webex" => config.webex.is_some(), - "feishu" => config.feishu.is_some(), - "dingtalk" => config.dingtalk.is_some(), - "pumble" => config.pumble.is_some(), - "flock" => config.flock.is_some(), - "twist" => config.twist.is_some(), - "zulip" => config.zulip.is_some(), - "irc" => config.irc.is_some(), - "xmpp" => config.xmpp.is_some(), - "gitter" => config.gitter.is_some(), - "discourse" => config.discourse.is_some(), - "revolt" => config.revolt.is_some(), - "guilded" => config.guilded.is_some(), - "nextcloud" => config.nextcloud.is_some(), - "rocketchat" => config.rocketchat.is_some(), - "twitch" => config.twitch.is_some(), - "ntfy" => config.ntfy.is_some(), - "gotify" => config.gotify.is_some(), - "webhook" => config.webhook.is_some(), - "mumble" => config.mumble.is_some(), - _ => false, - } -} - -/// Build a JSON field descriptor, checking env var presence but never exposing secrets. -/// For non-secret fields, includes the actual config value from `config_values` if available. -fn build_field_json( - f: &ChannelField, - config_values: Option<&serde_json::Value>, -) -> serde_json::Value { - let has_value = f - .env_var - .map(|ev| std::env::var(ev).map(|v| !v.is_empty()).unwrap_or(false)) - .unwrap_or(false); - let mut field = serde_json::json!({ - "key": f.key, - "label": f.label, - "type": f.field_type.as_str(), - "env_var": f.env_var, - "required": f.required, - "has_value": has_value, - "placeholder": f.placeholder, - "advanced": f.advanced, - }); - // For non-secret fields, include the actual saved config value so the - // dashboard can pre-populate forms when editing existing configs. - if f.env_var.is_none() { - if let Some(obj) = config_values.and_then(|v| v.as_object()) { - if let Some(val) = obj.get(f.key) { - // Convert arrays to comma-separated string for list fields - let display_val = if f.field_type == FieldType::List { - if let Some(arr) = val.as_array() { - serde_json::Value::String( - arr.iter() - .filter_map(|v| { - v.as_str() - .map(|s| s.to_string()) - .or_else(|| Some(v.to_string())) - }) - .collect::>() - .join(", "), - ) - } else { - val.clone() - } - } else { - val.clone() - }; - field["value"] = display_val; - if !val.is_null() - && val.as_str().map(|s| !s.is_empty()).unwrap_or(true) - { - field["has_value"] = serde_json::Value::Bool(true); - } - } - } - } - field -} - -/// Find a channel definition by name. -fn find_channel_meta(name: &str) -> Option<&'static ChannelMeta> { - CHANNEL_REGISTRY.iter().find(|c| c.name == name) -} - -/// Serialize a channel's config to a JSON Value for pre-populating dashboard forms. -fn channel_config_values( - config: &openfang_types::config::ChannelsConfig, - name: &str, -) -> Option { - match name { - "telegram" => config.telegram.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "discord" => config.discord.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "slack" => config.slack.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "whatsapp" => config.whatsapp.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "signal" => config.signal.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "matrix" => config.matrix.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "email" => config.email.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "teams" => config.teams.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "mattermost" => config.mattermost.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "irc" => config.irc.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "google_chat" => config.google_chat.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "twitch" => config.twitch.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "rocketchat" => config.rocketchat.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "zulip" => config.zulip.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "xmpp" => config.xmpp.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "line" => config.line.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "viber" => config.viber.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "messenger" => config.messenger.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "reddit" => config.reddit.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "mastodon" => config.mastodon.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "bluesky" => config.bluesky.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "feishu" => config.feishu.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "revolt" => config.revolt.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "nextcloud" => config.nextcloud.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "guilded" => config.guilded.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "keybase" => config.keybase.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "threema" => config.threema.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "nostr" => config.nostr.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "webex" => config.webex.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "pumble" => config.pumble.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "flock" => config.flock.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "twist" => config.twist.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "mumble" => config.mumble.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "dingtalk" => config.dingtalk.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "discourse" => config.discourse.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "gitter" => config.gitter.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "ntfy" => config.ntfy.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "gotify" => config.gotify.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "webhook" => config.webhook.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "linkedin" => config.linkedin.as_ref().and_then(|c| serde_json::to_value(c).ok()), - _ => None, - } -} - -/// GET /api/channels — List all 40 channel adapters with status and field metadata. -pub async fn list_channels(State(state): State>) -> impl IntoResponse { - // Read the live channels config (updated on every hot-reload) instead of the - // stale boot-time kernel.config, so newly configured channels show correctly. - let live_channels = state.channels_config.read().await; - let mut channels = Vec::new(); - let mut configured_count = 0u32; - - for meta in CHANNEL_REGISTRY { - let configured = is_channel_configured(&live_channels, meta.name); - if configured { - configured_count += 1; - } - - // Check if all required secret env vars are set - let has_token = meta - .fields - .iter() - .filter(|f| f.required && f.env_var.is_some()) - .all(|f| { - f.env_var - .map(|ev| std::env::var(ev).map(|v| !v.is_empty()).unwrap_or(false)) - .unwrap_or(true) - }); - - let config_vals = channel_config_values(&live_channels, meta.name); - let fields: Vec = meta - .fields - .iter() - .map(|f| build_field_json(f, config_vals.as_ref())) - .collect(); - - channels.push(serde_json::json!({ - "name": meta.name, - "display_name": meta.display_name, - "icon": meta.icon, - "description": meta.description, - "category": meta.category, - "difficulty": meta.difficulty, - "setup_time": meta.setup_time, - "quick_setup": meta.quick_setup, - "setup_type": meta.setup_type, - "configured": configured, - "has_token": has_token, - "fields": fields, - "setup_steps": meta.setup_steps, - "config_template": meta.config_template, - })); - } - - Json(serde_json::json!({ - "channels": channels, - "total": channels.len(), - "configured_count": configured_count, - })) -} - -/// POST /api/channels/{name}/configure — Save channel secrets + config fields. -pub async fn configure_channel( - State(state): State>, - Path(name): Path, - Json(body): Json, -) -> impl IntoResponse { - let meta = match find_channel_meta(&name) { - Some(m) => m, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Unknown channel"})), - ) - } - }; - - let fields = match body.get("fields").and_then(|v| v.as_object()) { - Some(f) => f, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Missing 'fields' object"})), - ) - } - }; - - let home = openfang_kernel::config::openfang_home(); - let secrets_path = home.join("secrets.env"); - let config_path = home.join("config.toml"); - let mut config_fields: HashMap = HashMap::new(); - - for field_def in meta.fields { - let value = fields - .get(field_def.key) - .and_then(|v| v.as_str()) - .unwrap_or(""); - if value.is_empty() { - continue; - } - - if let Some(env_var) = field_def.env_var { - // Secret field — write to secrets.env and set in process - if let Err(e) = write_secret_env(&secrets_path, env_var, value) { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Failed to write secret: {e}")})), - ); - } - // SAFETY: We are the only writer; this is a single-threaded config operation - unsafe { - std::env::set_var(env_var, value); - } - // Also write the env var NAME to config.toml so the channel section - // is not empty and the kernel knows which env var to read. - config_fields.insert( - field_def.key.to_string(), - (env_var.to_string(), FieldType::Text), - ); - } else { - // Config field — collect for TOML write with type info - config_fields.insert(field_def.key.to_string(), (value.to_string(), field_def.field_type)); - } - } - - // Write config.toml section - if let Err(e) = upsert_channel_config(&config_path, &name, &config_fields) { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Failed to write config: {e}")})), - ); - } - - // Hot-reload: activate the channel immediately - match crate::channel_bridge::reload_channels_from_disk(&state).await { - Ok(started) => { - let activated = started.iter().any(|s| s.eq_ignore_ascii_case(&name)); - ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "configured", - "channel": name, - "activated": activated, - "started_channels": started, - "note": if activated { - format!("{} activated successfully.", name) - } else { - "Channel configured but could not start (check credentials).".to_string() - } - })), - ) - } - Err(e) => { - tracing::warn!(error = %e, "Channel hot-reload failed after configure"); - ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "configured", - "channel": name, - "activated": false, - "note": format!("Configured, but hot-reload failed: {e}. Restart daemon to activate.") - })), - ) - } - } -} - -/// DELETE /api/channels/{name}/configure — Remove channel secrets + config section. -pub async fn remove_channel( - State(state): State>, - Path(name): Path, -) -> impl IntoResponse { - let meta = match find_channel_meta(&name) { - Some(m) => m, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Unknown channel"})), - ) - } - }; - - let home = openfang_kernel::config::openfang_home(); - let secrets_path = home.join("secrets.env"); - let config_path = home.join("config.toml"); - - // Remove all secret env vars for this channel - for field_def in meta.fields { - if let Some(env_var) = field_def.env_var { - let _ = remove_secret_env(&secrets_path, env_var); - // SAFETY: Single-threaded config operation - unsafe { - std::env::remove_var(env_var); - } - } - } - - // Remove config section - if let Err(e) = remove_channel_config(&config_path, &name) { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Failed to remove config: {e}")})), - ); - } - - // Hot-reload: deactivate the channel immediately - match crate::channel_bridge::reload_channels_from_disk(&state).await { - Ok(started) => ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "removed", - "channel": name, - "remaining_channels": started, - "note": format!("{} deactivated.", name) - })), - ), - Err(e) => { - tracing::warn!(error = %e, "Channel hot-reload failed after remove"); - ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "removed", - "channel": name, - "note": format!("Removed, but hot-reload failed: {e}. Restart daemon to fully deactivate.") - })), - ) - } - } -} - -/// POST /api/channels/{name}/test — Basic connectivity check for a channel. -pub async fn test_channel(Path(name): Path) -> impl IntoResponse { - let meta = match find_channel_meta(&name) { - Some(m) => m, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"status": "error", "message": "Unknown channel"})), - ) - } - }; - - // Check all required env vars are set - let mut missing = Vec::new(); - for field_def in meta.fields { - if field_def.required { - if let Some(env_var) = field_def.env_var { - if std::env::var(env_var).map(|v| v.is_empty()).unwrap_or(true) { - missing.push(env_var); - } - } - } - } - - if !missing.is_empty() { - return ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "error", - "message": format!("Missing required env vars: {}", missing.join(", ")) - })), - ); - } - - ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "ok", - "message": format!("All required credentials for {} are set.", meta.display_name) - })), - ) -} - -/// POST /api/channels/reload — Manually trigger a channel hot-reload from disk config. -pub async fn reload_channels(State(state): State>) -> impl IntoResponse { - match crate::channel_bridge::reload_channels_from_disk(&state).await { - Ok(started) => ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "ok", - "started": started, - })), - ), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "status": "error", - "error": e, - })), - ), - } -} - -// --------------------------------------------------------------------------- -// WhatsApp QR login flow (OpenClaw-style) -// --------------------------------------------------------------------------- - -/// POST /api/channels/whatsapp/qr/start — Start a WhatsApp Web QR login session. -/// -/// If a WhatsApp Web gateway is available (e.g. a Baileys-based bridge process), -/// this proxies the request and returns a base64 QR code data URL. If no gateway -/// is running, it returns instructions to set one up. -pub async fn whatsapp_qr_start() -> impl IntoResponse { - // Check for WhatsApp Web gateway URL in config or env - let gateway_url = std::env::var("WHATSAPP_WEB_GATEWAY_URL").unwrap_or_default(); - - if gateway_url.is_empty() { - return Json(serde_json::json!({ - "available": false, - "message": "WhatsApp Web gateway not running. Start the gateway or use Business API mode.", - "help": "Run: npx openfang-whatsapp-gateway (or set WHATSAPP_WEB_GATEWAY_URL)" - })); - } - - // Try to reach the gateway and start a QR session. - // Uses a raw HTTP request via tokio TcpStream to avoid adding reqwest as a runtime dep. - let start_url = format!("{}/login/start", gateway_url.trim_end_matches('/')); - match gateway_http_post(&start_url).await { - Ok(body) => { - let qr_url = body - .get("qr_data_url") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let sid = body - .get("session_id") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let msg = body - .get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or("Scan this QR code with WhatsApp → Linked Devices"); - let connected = body - .get("connected") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - Json(serde_json::json!({ - "available": true, - "qr_data_url": qr_url, - "session_id": sid, - "message": msg, - "connected": connected, - })) - } - Err(e) => Json(serde_json::json!({ - "available": false, - "message": format!("Could not reach WhatsApp Web gateway: {e}"), - "help": "Make sure the gateway is running at the configured URL" - })), - } -} - -/// GET /api/channels/whatsapp/qr/status — Poll for QR scan completion. -/// -/// After calling `/qr/start`, the frontend polls this to check if the user -/// has scanned the QR code and the WhatsApp Web session is connected. -pub async fn whatsapp_qr_status( - axum::extract::Query(params): axum::extract::Query>, -) -> impl IntoResponse { - let gateway_url = std::env::var("WHATSAPP_WEB_GATEWAY_URL").unwrap_or_default(); - - if gateway_url.is_empty() { - return Json(serde_json::json!({ - "connected": false, - "message": "Gateway not available" - })); - } - - let session_id = params.get("session_id").cloned().unwrap_or_default(); - let status_url = format!( - "{}/login/status?session_id={}", - gateway_url.trim_end_matches('/'), - session_id - ); - - match gateway_http_get(&status_url).await { - Ok(body) => { - let connected = body - .get("connected") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - let msg = body - .get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or("Waiting for scan..."); - let expired = body - .get("expired") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - Json(serde_json::json!({ - "connected": connected, - "message": msg, - "expired": expired, - })) - } - Err(_) => Json(serde_json::json!({ "connected": false, "message": "Gateway unreachable" })), - } -} - -/// Lightweight HTTP POST to a gateway URL. Returns parsed JSON body. -async fn gateway_http_post(url_with_path: &str) -> Result { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - // Split into base URL + path from the full URL like "http://127.0.0.1:3009/login/start" - let without_scheme = url_with_path - .strip_prefix("http://") - .or_else(|| url_with_path.strip_prefix("https://")) - .unwrap_or(url_with_path); - let (host_port, path) = if let Some(idx) = without_scheme.find('/') { - (&without_scheme[..idx], &without_scheme[idx..]) - } else { - (without_scheme, "/") - }; - let (host, port) = if let Some((h, p)) = host_port.rsplit_once(':') { - (h, p.parse().unwrap_or(3009u16)) - } else { - (host_port, 3009u16) - }; - - let mut stream = tokio::net::TcpStream::connect(format!("{host}:{port}")) - .await - .map_err(|e| format!("Connect failed: {e}"))?; - - let req = format!( - "POST {path} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}" - ); - stream - .write_all(req.as_bytes()) - .await - .map_err(|e| format!("Write failed: {e}"))?; - - let mut buf = Vec::new(); - stream - .read_to_end(&mut buf) - .await - .map_err(|e| format!("Read failed: {e}"))?; - let response = String::from_utf8_lossy(&buf); - - // Find the JSON body after the blank line separating headers from body - if let Some(idx) = response.find("\r\n\r\n") { - let body_str = &response[idx + 4..]; - serde_json::from_str(body_str.trim()).map_err(|e| format!("Parse failed: {e}")) - } else { - Err("No HTTP body in response".to_string()) - } -} - -/// Lightweight HTTP GET to a gateway URL. Returns parsed JSON body. -async fn gateway_http_get(url_with_path: &str) -> Result { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - let without_scheme = url_with_path - .strip_prefix("http://") - .or_else(|| url_with_path.strip_prefix("https://")) - .unwrap_or(url_with_path); - let (host_port, path_and_query) = if let Some(idx) = without_scheme.find('/') { - (&without_scheme[..idx], &without_scheme[idx..]) - } else { - (without_scheme, "/") - }; - let (host, port) = if let Some((h, p)) = host_port.rsplit_once(':') { - (h, p.parse().unwrap_or(3009u16)) - } else { - (host_port, 3009u16) - }; - - let mut stream = tokio::net::TcpStream::connect(format!("{host}:{port}")) - .await - .map_err(|e| format!("Connect failed: {e}"))?; - - let req = format!( - "GET {path_and_query} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n" - ); - stream - .write_all(req.as_bytes()) - .await - .map_err(|e| format!("Write failed: {e}"))?; - - let mut buf = Vec::new(); - stream - .read_to_end(&mut buf) - .await - .map_err(|e| format!("Read failed: {e}"))?; - let response = String::from_utf8_lossy(&buf); - - if let Some(idx) = response.find("\r\n\r\n") { - let body_str = &response[idx + 4..]; - serde_json::from_str(body_str.trim()).map_err(|e| format!("Parse failed: {e}")) - } else { - Err("No HTTP body in response".to_string()) - } -} - -// --------------------------------------------------------------------------- -// Template endpoints -// --------------------------------------------------------------------------- - -/// GET /api/templates — List available agent templates. -pub async fn list_templates() -> impl IntoResponse { - let agents_dir = openfang_kernel::config::openfang_home().join("agents"); - let mut templates = Vec::new(); - - if let Ok(entries) = std::fs::read_dir(&agents_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - let manifest_path = path.join("agent.toml"); - if manifest_path.exists() { - let name = path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - - let description = std::fs::read_to_string(&manifest_path) - .ok() - .and_then(|content| toml::from_str::(&content).ok()) - .map(|m| m.description) - .unwrap_or_default(); - - templates.push(serde_json::json!({ - "name": name, - "description": description, - })); - } - } - } - } - - Json(serde_json::json!({ - "templates": templates, - "total": templates.len(), - })) -} - -/// GET /api/templates/:name — Get template details. -pub async fn get_template(Path(name): Path) -> impl IntoResponse { - let agents_dir = openfang_kernel::config::openfang_home().join("agents"); - let manifest_path = agents_dir.join(&name).join("agent.toml"); - - if !manifest_path.exists() { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Template not found"})), - ); - } - - match std::fs::read_to_string(&manifest_path) { - Ok(content) => match toml::from_str::(&content) { - Ok(manifest) => ( - StatusCode::OK, - Json(serde_json::json!({ - "name": name, - "manifest": { - "name": manifest.name, - "description": manifest.description, - "module": manifest.module, - "tags": manifest.tags, - "model": { - "provider": manifest.model.provider, - "model": manifest.model.model, - }, - "capabilities": { - "tools": manifest.capabilities.tools, - "network": manifest.capabilities.network, - }, - }, - "manifest_toml": content, - })), - ), - Err(e) => { - tracing::warn!("Invalid template manifest for '{name}': {e}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "Invalid template manifest"})), - ) - } - }, - Err(e) => { - tracing::warn!("Failed to read template '{name}': {e}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "Failed to read template"})), - ) - } - } -} - -// --------------------------------------------------------------------------- -// Memory endpoints -// --------------------------------------------------------------------------- - -/// GET /api/memory/agents/:id/kv — List KV pairs for an agent. -/// -/// Note: memory_store tool writes to a shared namespace, so we read from that -/// same namespace regardless of which agent ID is in the URL. -pub async fn get_agent_kv( - State(state): State>, - Path(_id): Path, -) -> impl IntoResponse { - let agent_id = openfang_kernel::kernel::shared_memory_agent_id(); - - match state.kernel.memory.list_kv(agent_id) { - Ok(pairs) => { - let kv: Vec = pairs - .into_iter() - .map(|(k, v)| serde_json::json!({"key": k, "value": v})) - .collect(); - (StatusCode::OK, Json(serde_json::json!({"kv_pairs": kv}))) - } - Err(e) => { - tracing::warn!("Memory list_kv failed: {e}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "Memory operation failed"})), - ) - } - } -} - -/// GET /api/memory/agents/:id/kv/:key — Get a specific KV value. -pub async fn get_agent_kv_key( - State(state): State>, - Path((_id, key)): Path<(String, String)>, -) -> impl IntoResponse { - let agent_id = openfang_kernel::kernel::shared_memory_agent_id(); - - match state.kernel.memory.structured_get(agent_id, &key) { - Ok(Some(val)) => ( - StatusCode::OK, - Json(serde_json::json!({"key": key, "value": val})), - ), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Key not found"})), - ), - Err(e) => { - tracing::warn!("Memory get failed for key '{key}': {e}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "Memory operation failed"})), - ) - } - } -} - -/// PUT /api/memory/agents/:id/kv/:key — Set a KV value. -pub async fn set_agent_kv_key( - State(state): State>, - Path((_id, key)): Path<(String, String)>, - Json(body): Json, -) -> impl IntoResponse { - let agent_id = openfang_kernel::kernel::shared_memory_agent_id(); - - let value = body.get("value").cloned().unwrap_or(body); - - match state.kernel.memory.structured_set(agent_id, &key, value) { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "stored", "key": key})), - ), - Err(e) => { - tracing::warn!("Memory set failed for key '{key}': {e}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "Memory operation failed"})), - ) - } - } -} - -/// DELETE /api/memory/agents/:id/kv/:key — Delete a KV value. -pub async fn delete_agent_kv_key( - State(state): State>, - Path((_id, key)): Path<(String, String)>, -) -> impl IntoResponse { - let agent_id = openfang_kernel::kernel::shared_memory_agent_id(); - - match state.kernel.memory.structured_delete(agent_id, &key) { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "deleted", "key": key})), - ), - Err(e) => { - tracing::warn!("Memory delete failed for key '{key}': {e}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "Memory operation failed"})), - ) - } - } -} - -/// GET /api/health — Minimal liveness probe (public, no auth required). -/// Returns only status and version to prevent information leakage. -/// Use GET /api/health/detail for full diagnostics (requires auth). -pub async fn health(State(state): State>) -> impl IntoResponse { - // Check database connectivity - let shared_id = openfang_types::agent::AgentId(uuid::Uuid::from_bytes([ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, - ])); - let db_ok = state - .kernel - .memory - .structured_get(shared_id, "__health_check__") - .is_ok(); - - let status = if db_ok { "ok" } else { "degraded" }; - - Json(serde_json::json!({ - "status": status, - "version": env!("CARGO_PKG_VERSION"), - })) -} - -/// GET /api/health/detail — Full health diagnostics (requires auth). -pub async fn health_detail(State(state): State>) -> impl IntoResponse { - let health = state.kernel.supervisor.health(); - - let shared_id = openfang_types::agent::AgentId(uuid::Uuid::from_bytes([ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, - ])); - let db_ok = state - .kernel - .memory - .structured_get(shared_id, "__health_check__") - .is_ok(); - - let config_warnings = state.kernel.config.validate(); - let status = if db_ok { "ok" } else { "degraded" }; - - Json(serde_json::json!({ - "status": status, - "version": env!("CARGO_PKG_VERSION"), - "uptime_seconds": state.started_at.elapsed().as_secs(), - "panic_count": health.panic_count, - "restart_count": health.restart_count, - "agent_count": state.kernel.registry.count(), - "database": if db_ok { "connected" } else { "error" }, - "config_warnings": config_warnings, - })) -} - -// --------------------------------------------------------------------------- -// Prometheus metrics endpoint -// --------------------------------------------------------------------------- - -/// GET /api/metrics — Prometheus text-format metrics. -/// -/// Returns counters and gauges for monitoring OpenFang in production: -/// - `openfang_agents_active` — number of active agents -/// - `openfang_uptime_seconds` — seconds since daemon started -/// - `openfang_tokens_total` — total tokens consumed (per agent) -/// - `openfang_tool_calls_total` — total tool calls (per agent) -/// - `openfang_panics_total` — supervisor panic count -/// - `openfang_restarts_total` — supervisor restart count -pub async fn prometheus_metrics(State(state): State>) -> impl IntoResponse { - let mut out = String::with_capacity(2048); - - // Uptime - let uptime = state.started_at.elapsed().as_secs(); - out.push_str("# HELP openfang_uptime_seconds Time since daemon started.\n"); - out.push_str("# TYPE openfang_uptime_seconds gauge\n"); - out.push_str(&format!("openfang_uptime_seconds {uptime}\n\n")); - - // Active agents - let agents = state.kernel.registry.list(); - let active = agents - .iter() - .filter(|a| matches!(a.state, openfang_types::agent::AgentState::Running)) - .count(); - out.push_str("# HELP openfang_agents_active Number of active agents.\n"); - out.push_str("# TYPE openfang_agents_active gauge\n"); - out.push_str(&format!("openfang_agents_active {active}\n")); - out.push_str("# HELP openfang_agents_total Total number of registered agents.\n"); - out.push_str("# TYPE openfang_agents_total gauge\n"); - out.push_str(&format!("openfang_agents_total {}\n\n", agents.len())); - - // Per-agent token and tool usage - out.push_str("# HELP openfang_tokens_total Total tokens consumed (rolling hourly window).\n"); - out.push_str("# TYPE openfang_tokens_total gauge\n"); - out.push_str("# HELP openfang_tool_calls_total Total tool calls (rolling hourly window).\n"); - out.push_str("# TYPE openfang_tool_calls_total gauge\n"); - for agent in &agents { - let name = &agent.name; - let provider = &agent.manifest.model.provider; - let model = &agent.manifest.model.model; - if let Some((tokens, tools)) = state.kernel.scheduler.get_usage(agent.id) { - out.push_str(&format!( - "openfang_tokens_total{{agent=\"{name}\",provider=\"{provider}\",model=\"{model}\"}} {tokens}\n" - )); - out.push_str(&format!( - "openfang_tool_calls_total{{agent=\"{name}\"}} {tools}\n" - )); - } - } - out.push('\n'); - - // Supervisor health - let health = state.kernel.supervisor.health(); - out.push_str("# HELP openfang_panics_total Total supervisor panics since start.\n"); - out.push_str("# TYPE openfang_panics_total counter\n"); - out.push_str(&format!("openfang_panics_total {}\n", health.panic_count)); - out.push_str("# HELP openfang_restarts_total Total supervisor restarts since start.\n"); - out.push_str("# TYPE openfang_restarts_total counter\n"); - out.push_str(&format!( - "openfang_restarts_total {}\n\n", - health.restart_count - )); - - // Version info - out.push_str("# HELP openfang_info OpenFang version and build info.\n"); - out.push_str("# TYPE openfang_info gauge\n"); - out.push_str(&format!( - "openfang_info{{version=\"{}\"}} 1\n", - env!("CARGO_PKG_VERSION") - )); - - ( - StatusCode::OK, - [( - axum::http::header::CONTENT_TYPE, - "text/plain; version=0.0.4; charset=utf-8", - )], - out, - ) -} - -// --------------------------------------------------------------------------- -// Skills endpoints -// --------------------------------------------------------------------------- - -/// GET /api/skills — List installed skills. -pub async fn list_skills(State(state): State>) -> impl IntoResponse { - let skills_dir = state.kernel.config.home_dir.join("skills"); - let mut registry = openfang_skills::registry::SkillRegistry::new(skills_dir); - let _ = registry.load_all(); - - let skills: Vec = registry - .list() - .iter() - .map(|s| { - let source = match &s.manifest.source { - Some(openfang_skills::SkillSource::ClawHub { slug, version }) => { - serde_json::json!({"type": "clawhub", "slug": slug, "version": version}) - } - Some(openfang_skills::SkillSource::OpenClaw) => { - serde_json::json!({"type": "openclaw"}) - } - Some(openfang_skills::SkillSource::Bundled) => { - serde_json::json!({"type": "bundled"}) - } - Some(openfang_skills::SkillSource::Native) | None => { - serde_json::json!({"type": "local"}) - } - }; - serde_json::json!({ - "name": s.manifest.skill.name, - "description": s.manifest.skill.description, - "version": s.manifest.skill.version, - "author": s.manifest.skill.author, - "runtime": format!("{:?}", s.manifest.runtime.runtime_type), - "tools_count": s.manifest.tools.provided.len(), - "tags": s.manifest.skill.tags, - "enabled": s.enabled, - "source": source, - "has_prompt_context": s.manifest.prompt_context.is_some(), - }) - }) - .collect(); - - Json(serde_json::json!({ "skills": skills, "total": skills.len() })) -} - -/// POST /api/skills/install — Install a skill from FangHub (GitHub). -pub async fn install_skill( - State(state): State>, - Json(req): Json, -) -> impl IntoResponse { - let skills_dir = state.kernel.config.home_dir.join("skills"); - let config = openfang_skills::marketplace::MarketplaceConfig::default(); - let client = openfang_skills::marketplace::MarketplaceClient::new(config); - - match client.install(&req.name, &skills_dir).await { - Ok(version) => { - // Hot-reload so agents see the new skill immediately - state.kernel.reload_skills(); - ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "installed", - "name": req.name, - "version": version, - })), - ) - } - Err(e) => { - tracing::warn!("Skill install failed: {e}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Install failed: {e}")})), - ) - } - } -} - -/// POST /api/skills/uninstall — Uninstall a skill. -pub async fn uninstall_skill( - State(state): State>, - Json(req): Json, -) -> impl IntoResponse { - let skills_dir = state.kernel.config.home_dir.join("skills"); - let mut registry = openfang_skills::registry::SkillRegistry::new(skills_dir); - let _ = registry.load_all(); - - match registry.remove(&req.name) { - Ok(()) => { - // Hot-reload so agents stop seeing the removed skill - state.kernel.reload_skills(); - ( - StatusCode::OK, - Json(serde_json::json!({"status": "uninstalled", "name": req.name})), - ) - } - Err(e) => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("{e}")})), - ), - } -} - -/// GET /api/marketplace/search — Search the FangHub marketplace. -pub async fn marketplace_search( - Query(params): Query>, -) -> impl IntoResponse { - let query = params.get("q").cloned().unwrap_or_default(); - if query.is_empty() { - return Json(serde_json::json!({"results": [], "total": 0})); - } - - let config = openfang_skills::marketplace::MarketplaceConfig::default(); - let client = openfang_skills::marketplace::MarketplaceClient::new(config); - - match client.search(&query).await { - Ok(results) => { - let items: Vec = results - .iter() - .map(|r| { - serde_json::json!({ - "name": r.name, - "description": r.description, - "stars": r.stars, - "url": r.url, - }) - }) - .collect(); - Json(serde_json::json!({"results": items, "total": items.len()})) - } - Err(e) => { - tracing::warn!("Marketplace search failed: {e}"); - Json(serde_json::json!({"results": [], "total": 0, "error": format!("{e}")})) - } - } -} - -// --------------------------------------------------------------------------- -// ClawHub (OpenClaw ecosystem) endpoints -// --------------------------------------------------------------------------- - -/// GET /api/clawhub/search — Search ClawHub skills using vector/semantic search. -/// -/// Query parameters: -/// - `q` — search query (required) -/// - `limit` — max results (default: 20, max: 50) -pub async fn clawhub_search( - State(state): State>, - Query(params): Query>, -) -> impl IntoResponse { - let query = params.get("q").cloned().unwrap_or_default(); - if query.is_empty() { - return ( - StatusCode::OK, - Json(serde_json::json!({"items": [], "next_cursor": null})), - ); - } - - let limit: u32 = params - .get("limit") - .and_then(|v| v.parse().ok()) - .unwrap_or(20); - - // Check cache (120s TTL) - let cache_key = format!("search:{}:{}", query, limit); - if let Some(entry) = state.clawhub_cache.get(&cache_key) { - if entry.0.elapsed().as_secs() < 120 { - return (StatusCode::OK, Json(entry.1.clone())); - } - } - - let cache_dir = state.kernel.config.home_dir.join(".cache").join("clawhub"); - let client = openfang_skills::clawhub::ClawHubClient::new(cache_dir); - - match client.search(&query, limit).await { - Ok(results) => { - let items: Vec = results - .results - .iter() - .map(|e| { - serde_json::json!({ - "slug": e.slug, - "name": e.display_name, - "description": e.summary, - "version": e.version, - "score": e.score, - "updated_at": e.updated_at, - }) - }) - .collect(); - let resp = serde_json::json!({ - "items": items, - "next_cursor": null, - }); - state.clawhub_cache.insert(cache_key, (Instant::now(), resp.clone())); - (StatusCode::OK, Json(resp)) - } - Err(e) => { - let msg = format!("{e}"); - tracing::warn!("ClawHub search failed: {msg}"); - // Propagate 429 status instead of masking as 200 - let status = if msg.contains("429") || msg.contains("rate limit") { - StatusCode::TOO_MANY_REQUESTS - } else { - StatusCode::OK - }; - ( - status, - Json( - serde_json::json!({"items": [], "next_cursor": null, "error": msg}), - ), - ) - } - } -} - -/// GET /api/clawhub/browse — Browse ClawHub skills by sort order. -/// -/// Query parameters: -/// - `sort` — sort order: "trending", "downloads", "stars", "updated", "rating" (default: "trending") -/// - `limit` — max results (default: 20, max: 50) -/// - `cursor` — pagination cursor from previous response -pub async fn clawhub_browse( - State(state): State>, - Query(params): Query>, -) -> impl IntoResponse { - let sort = match params.get("sort").map(|s| s.as_str()) { - Some("downloads") => openfang_skills::clawhub::ClawHubSort::Downloads, - Some("stars") => openfang_skills::clawhub::ClawHubSort::Stars, - Some("updated") => openfang_skills::clawhub::ClawHubSort::Updated, - Some("rating") => openfang_skills::clawhub::ClawHubSort::Rating, - _ => openfang_skills::clawhub::ClawHubSort::Trending, - }; - - let limit: u32 = params - .get("limit") - .and_then(|v| v.parse().ok()) - .unwrap_or(20); - - let cursor = params.get("cursor").map(|s| s.as_str()); - - // Check cache (120s TTL) - let cache_key = format!("browse:{:?}:{}:{}", sort, limit, cursor.unwrap_or("")); - if let Some(entry) = state.clawhub_cache.get(&cache_key) { - if entry.0.elapsed().as_secs() < 120 { - return (StatusCode::OK, Json(entry.1.clone())); - } - } - - let cache_dir = state.kernel.config.home_dir.join(".cache").join("clawhub"); - let client = openfang_skills::clawhub::ClawHubClient::new(cache_dir); - - match client.browse(sort, limit, cursor).await { - Ok(results) => { - let items: Vec = results - .items - .iter() - .map(clawhub_browse_entry_to_json) - .collect(); - let resp = serde_json::json!({ - "items": items, - "next_cursor": results.next_cursor, - }); - state.clawhub_cache.insert(cache_key, (Instant::now(), resp.clone())); - (StatusCode::OK, Json(resp)) - } - Err(e) => { - let msg = format!("{e}"); - tracing::warn!("ClawHub browse failed: {msg}"); - let status = if msg.contains("429") || msg.contains("rate limit") { - StatusCode::TOO_MANY_REQUESTS - } else { - StatusCode::OK - }; - ( - status, - Json( - serde_json::json!({"items": [], "next_cursor": null, "error": msg}), - ), - ) - } - } -} - -/// GET /api/clawhub/skill/{slug} — Get detailed info about a ClawHub skill. -pub async fn clawhub_skill_detail( - State(state): State>, - Path(slug): Path, -) -> impl IntoResponse { - let cache_dir = state.kernel.config.home_dir.join(".cache").join("clawhub"); - let client = openfang_skills::clawhub::ClawHubClient::new(cache_dir); - - let skills_dir = state.kernel.config.home_dir.join("skills"); - let is_installed = client.is_installed(&slug, &skills_dir); - - match client.get_skill(&slug).await { - Ok(detail) => { - let version = detail - .latest_version - .as_ref() - .map(|v| v.version.as_str()) - .unwrap_or(""); - let author = detail - .owner - .as_ref() - .map(|o| o.handle.as_str()) - .unwrap_or(""); - let author_name = detail - .owner - .as_ref() - .map(|o| o.display_name.as_str()) - .unwrap_or(""); - let author_image = detail - .owner - .as_ref() - .map(|o| o.image.as_str()) - .unwrap_or(""); - - ( - StatusCode::OK, - Json(serde_json::json!({ - "slug": detail.skill.slug, - "name": detail.skill.display_name, - "description": detail.skill.summary, - "version": version, - "downloads": detail.skill.stats.downloads, - "stars": detail.skill.stats.stars, - "author": author, - "author_name": author_name, - "author_image": author_image, - "tags": detail.skill.tags, - "updated_at": detail.skill.updated_at, - "created_at": detail.skill.created_at, - "installed": is_installed, - })), - ) - } - Err(e) => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("{e}")})), - ), - } -} - -/// GET /api/clawhub/skill/{slug}/code — Fetch the source code (SKILL.md) of a ClawHub skill. -pub async fn clawhub_skill_code( - State(state): State>, - Path(slug): Path, -) -> impl IntoResponse { - let cache_dir = state.kernel.config.home_dir.join(".cache").join("clawhub"); - let client = openfang_skills::clawhub::ClawHubClient::new(cache_dir); - - // Try to fetch SKILL.md first, then fallback to package.json - let mut code = String::new(); - let mut filename = String::new(); - - if let Ok(content) = client.get_file(&slug, "SKILL.md").await { - code = content; - filename = "SKILL.md".to_string(); - } else if let Ok(content) = client.get_file(&slug, "package.json").await { - code = content; - filename = "package.json".to_string(); - } else if let Ok(content) = client.get_file(&slug, "skill.toml").await { - code = content; - filename = "skill.toml".to_string(); - } - - if code.is_empty() { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "No source code found for this skill"})), - ); - } - - ( - StatusCode::OK, - Json(serde_json::json!({ - "slug": slug, - "filename": filename, - "code": code, - })), - ) -} - -/// POST /api/clawhub/install — Install a skill from ClawHub. -/// -/// Runs the full security pipeline: SHA256 verification, format detection, -/// manifest security scan, prompt injection scan, and binary dependency check. -pub async fn clawhub_install( - State(state): State>, - Json(req): Json, -) -> impl IntoResponse { - let skills_dir = state.kernel.config.home_dir.join("skills"); - let cache_dir = state.kernel.config.home_dir.join(".cache").join("clawhub"); - let client = openfang_skills::clawhub::ClawHubClient::new(cache_dir); - - // Check if already installed - if client.is_installed(&req.slug, &skills_dir) { - return ( - StatusCode::CONFLICT, - Json(serde_json::json!({ - "error": format!("Skill '{}' is already installed", req.slug), - "status": "already_installed", - })), - ); - } - - match client.install(&req.slug, &skills_dir).await { - Ok(result) => { - let warnings: Vec = result - .warnings - .iter() - .map(|w| { - serde_json::json!({ - "severity": format!("{:?}", w.severity), - "message": w.message, - }) - }) - .collect(); - - let translations: Vec = result - .tool_translations - .iter() - .map(|(from, to)| serde_json::json!({"from": from, "to": to})) - .collect(); - - ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "installed", - "name": result.skill_name, - "version": result.version, - "slug": result.slug, - "is_prompt_only": result.is_prompt_only, - "warnings": warnings, - "tool_translations": translations, - })), - ) - } - Err(e) => { - let msg = format!("{e}"); - let status = if msg.contains("SecurityBlocked") { - StatusCode::FORBIDDEN - } else if msg.contains("429") || msg.contains("rate limit") { - StatusCode::TOO_MANY_REQUESTS - } else if msg.contains("Network error") || msg.contains("returned 4") || msg.contains("returned 5") { - StatusCode::BAD_GATEWAY - } else { - StatusCode::INTERNAL_SERVER_ERROR - }; - tracing::warn!("ClawHub install failed: {msg}"); - (status, Json(serde_json::json!({"error": msg}))) - } - } -} - -/// Convert a browse entry (nested stats/tags) to a flat JSON object for the frontend. -fn clawhub_browse_entry_to_json( - entry: &openfang_skills::clawhub::ClawHubBrowseEntry, -) -> serde_json::Value { - let version = openfang_skills::clawhub::ClawHubClient::entry_version(entry); - serde_json::json!({ - "slug": entry.slug, - "name": entry.display_name, - "description": entry.summary, - "version": version, - "downloads": entry.stats.downloads, - "stars": entry.stats.stars, - "updated_at": entry.updated_at, - }) -} - -// --------------------------------------------------------------------------- -// Hands endpoints -// --------------------------------------------------------------------------- - -/// Detect the server platform for install command selection. -fn server_platform() -> &'static str { - if cfg!(target_os = "macos") { - "macos" - } else if cfg!(target_os = "windows") { - "windows" - } else { - "linux" - } -} - -/// GET /api/hands — List all hand definitions (marketplace). -pub async fn list_hands(State(state): State>) -> impl IntoResponse { - let defs = state.kernel.hand_registry.list_definitions(); - let hands: Vec = defs - .iter() - .map(|d| { - let reqs = state - .kernel - .hand_registry - .check_requirements(&d.id) - .unwrap_or_default(); - let all_satisfied = reqs.iter().all(|(_, ok)| *ok); - serde_json::json!({ - "id": d.id, - "name": d.name, - "description": d.description, - "category": d.category, - "icon": d.icon, - "tools": d.tools, - "requirements_met": all_satisfied, - "requirements": reqs.iter().map(|(r, ok)| serde_json::json!({ - "key": r.key, - "label": r.label, - "satisfied": ok, - })).collect::>(), - "dashboard_metrics": d.dashboard.metrics.len(), - "has_settings": !d.settings.is_empty(), - "settings_count": d.settings.len(), - }) - }) - .collect(); - - Json(serde_json::json!({ "hands": hands, "total": hands.len() })) -} - -/// GET /api/hands/active — List active hand instances. -pub async fn list_active_hands(State(state): State>) -> impl IntoResponse { - let instances = state.kernel.hand_registry.list_instances(); - let items: Vec = instances - .iter() - .map(|i| { - serde_json::json!({ - "instance_id": i.instance_id, - "hand_id": i.hand_id, - "status": format!("{}", i.status), - "agent_id": i.agent_id.map(|a| a.to_string()), - "agent_name": i.agent_name, - "activated_at": i.activated_at.to_rfc3339(), - "updated_at": i.updated_at.to_rfc3339(), - }) - }) - .collect(); - - Json(serde_json::json!({ "instances": items, "total": items.len() })) -} - -/// GET /api/hands/{hand_id} — Get a single hand definition with requirements check. -pub async fn get_hand( - State(state): State>, - Path(hand_id): Path, -) -> impl IntoResponse { - match state.kernel.hand_registry.get_definition(&hand_id) { - Some(def) => { - let reqs = state - .kernel - .hand_registry - .check_requirements(&hand_id) - .unwrap_or_default(); - let all_satisfied = reqs.iter().all(|(_, ok)| *ok); - let settings_status = state - .kernel - .hand_registry - .check_settings_availability(&hand_id) - .unwrap_or_default(); - ( - StatusCode::OK, - Json(serde_json::json!({ - "id": def.id, - "name": def.name, - "description": def.description, - "category": def.category, - "icon": def.icon, - "tools": def.tools, - "requirements_met": all_satisfied, - "requirements": reqs.iter().map(|(r, ok)| { - let mut req_json = serde_json::json!({ - "key": r.key, - "label": r.label, - "type": format!("{:?}", r.requirement_type), - "check_value": r.check_value, - "satisfied": ok, - }); - if let Some(ref desc) = r.description { - req_json["description"] = serde_json::json!(desc); - } - if let Some(ref install) = r.install { - req_json["install"] = serde_json::to_value(install).unwrap_or_default(); - } - req_json - }).collect::>(), - "server_platform": server_platform(), - "agent": { - "name": def.agent.name, - "description": def.agent.description, - "provider": if def.agent.provider == "default" { - &state.kernel.config.default_model.provider - } else { &def.agent.provider }, - "model": if def.agent.model == "default" { - &state.kernel.config.default_model.model - } else { &def.agent.model }, - }, - "dashboard": def.dashboard.metrics.iter().map(|m| serde_json::json!({ - "label": m.label, - "memory_key": m.memory_key, - "format": m.format, - })).collect::>(), - "settings": settings_status, - })), - ) - } - None => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("Hand not found: {hand_id}")})), - ), - } -} - -/// POST /api/hands/{hand_id}/check-deps — Re-check dependency status for a hand. -pub async fn check_hand_deps( - State(state): State>, - Path(hand_id): Path, -) -> impl IntoResponse { - match state.kernel.hand_registry.get_definition(&hand_id) { - Some(def) => { - let reqs = state - .kernel - .hand_registry - .check_requirements(&hand_id) - .unwrap_or_default(); - let all_satisfied = reqs.iter().all(|(_, ok)| *ok); - ( - StatusCode::OK, - Json(serde_json::json!({ - "hand_id": def.id, - "requirements_met": all_satisfied, - "server_platform": server_platform(), - "requirements": reqs.iter().map(|(r, ok)| { - let mut req_json = serde_json::json!({ - "key": r.key, - "label": r.label, - "type": format!("{:?}", r.requirement_type), - "check_value": r.check_value, - "satisfied": ok, - }); - if let Some(ref desc) = r.description { - req_json["description"] = serde_json::json!(desc); - } - if let Some(ref install) = r.install { - req_json["install"] = serde_json::to_value(install).unwrap_or_default(); - } - req_json - }).collect::>(), - })), - ) - } - None => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("Hand not found: {hand_id}")})), - ), - } -} - -/// POST /api/hands/{hand_id}/install-deps — Auto-install missing dependencies for a hand. -pub async fn install_hand_deps( - State(state): State>, - Path(hand_id): Path, -) -> impl IntoResponse { - let def = match state.kernel.hand_registry.get_definition(&hand_id) { - Some(d) => d.clone(), - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("Hand not found: {hand_id}")})), - ); - } - }; - - let reqs = state - .kernel - .hand_registry - .check_requirements(&hand_id) - .unwrap_or_default(); - - let platform = server_platform(); - let mut results = Vec::new(); - - for (req, already_satisfied) in &reqs { - if *already_satisfied { - results.push(serde_json::json!({ - "key": req.key, - "status": "already_installed", - "message": format!("{} is already available", req.label), - })); - continue; - } - - let install = match &req.install { - Some(i) => i, - None => { - results.push(serde_json::json!({ - "key": req.key, - "status": "skipped", - "message": "No install instructions available", - })); - continue; - } - }; - - // Pick the best install command for this platform - let cmd = match platform { - "windows" => install.windows.as_deref().or(install.pip.as_deref()), - "macos" => install.macos.as_deref().or(install.pip.as_deref()), - _ => install - .linux_apt - .as_deref() - .or(install.linux_dnf.as_deref()) - .or(install.linux_pacman.as_deref()) - .or(install.pip.as_deref()), - }; - - let cmd = match cmd { - Some(c) => c, - None => { - results.push(serde_json::json!({ - "key": req.key, - "status": "no_command", - "message": format!("No install command for platform: {platform}"), - })); - continue; - } - }; - - // Execute the install command - let (shell, flag) = if cfg!(windows) { - ("cmd", "/C") - } else { - ("sh", "-c") - }; - - // For winget on Windows, add --accept flags to avoid interactive prompts - let final_cmd = if cfg!(windows) && cmd.starts_with("winget ") { - format!("{cmd} --accept-source-agreements --accept-package-agreements") - } else { - cmd.to_string() - }; - - tracing::info!(hand = %hand_id, dep = %req.key, cmd = %final_cmd, "Auto-installing dependency"); - - let output = match tokio::time::timeout( - std::time::Duration::from_secs(300), - tokio::process::Command::new(shell) - .arg(flag) - .arg(&final_cmd) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .stdin(std::process::Stdio::null()) - .output(), - ) - .await - { - Ok(Ok(out)) => out, - Ok(Err(e)) => { - results.push(serde_json::json!({ - "key": req.key, - "status": "error", - "command": final_cmd, - "message": format!("Failed to execute: {e}"), - })); - continue; - } - Err(_) => { - results.push(serde_json::json!({ - "key": req.key, - "status": "timeout", - "command": final_cmd, - "message": "Installation timed out after 5 minutes", - })); - continue; - } - }; - - let exit_code = output.status.code().unwrap_or(-1); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - if exit_code == 0 { - results.push(serde_json::json!({ - "key": req.key, - "status": "installed", - "command": final_cmd, - "message": format!("{} installed successfully", req.label), - })); - } else { - // On Windows, winget may return non-zero even on success (e.g., already installed) - let combined = format!("{stdout}{stderr}"); - let likely_ok = combined.contains("already installed") - || combined.contains("No applicable update") - || combined.contains("No available upgrade"); - results.push(serde_json::json!({ - "key": req.key, - "status": if likely_ok { "installed" } else { "error" }, - "command": final_cmd, - "exit_code": exit_code, - "message": if likely_ok { - format!("{} is already installed", req.label) - } else { - let msg = stderr.chars().take(500).collect::(); - format!("Install failed (exit {}): {}", exit_code, msg.trim()) - }, - })); - } - } - - // On Windows, refresh PATH to pick up newly installed binaries from winget/pip - #[cfg(windows)] - { - let home = std::env::var("USERPROFILE").unwrap_or_default(); - if !home.is_empty() { - let winget_pkgs = - std::path::Path::new(&home).join("AppData\\Local\\Microsoft\\WinGet\\Packages"); - if winget_pkgs.is_dir() { - let mut extra_paths = Vec::new(); - if let Ok(entries) = std::fs::read_dir(&winget_pkgs) { - for entry in entries.flatten() { - let pkg_dir = entry.path(); - // Look for bin/ subdirectory (ffmpeg style) - if let Ok(sub_entries) = std::fs::read_dir(&pkg_dir) { - for sub in sub_entries.flatten() { - let bin_dir = sub.path().join("bin"); - if bin_dir.is_dir() { - extra_paths.push(bin_dir.to_string_lossy().to_string()); - } - } - } - // Direct exe in package dir (yt-dlp style) - if std::fs::read_dir(&pkg_dir) - .map(|rd| { - rd.flatten().any(|e| { - e.path().extension().map(|x| x == "exe").unwrap_or(false) - }) - }) - .unwrap_or(false) - { - extra_paths.push(pkg_dir.to_string_lossy().to_string()); - } - } - } - // Also add pip Scripts dir - let pip_scripts = - std::path::Path::new(&home).join("AppData\\Local\\Programs\\Python"); - if pip_scripts.is_dir() { - if let Ok(entries) = std::fs::read_dir(&pip_scripts) { - for entry in entries.flatten() { - let scripts = entry.path().join("Scripts"); - if scripts.is_dir() { - extra_paths.push(scripts.to_string_lossy().to_string()); - } - } - } - } - if !extra_paths.is_empty() { - let current_path = std::env::var("PATH").unwrap_or_default(); - let new_path = format!("{};{}", extra_paths.join(";"), current_path); - std::env::set_var("PATH", &new_path); - tracing::info!( - added = extra_paths.len(), - "Refreshed PATH with winget/pip directories" - ); - } - } - } - } - - // Re-check requirements after installation - let reqs_after = state - .kernel - .hand_registry - .check_requirements(&hand_id) - .unwrap_or_default(); - let all_satisfied = reqs_after.iter().all(|(_, ok)| *ok); - - ( - StatusCode::OK, - Json(serde_json::json!({ - "hand_id": def.id, - "results": results, - "requirements_met": all_satisfied, - "requirements": reqs_after.iter().map(|(r, ok)| { - serde_json::json!({ - "key": r.key, - "label": r.label, - "satisfied": ok, - }) - }).collect::>(), - })), - ) -} - -/// POST /api/hands/install — Install a hand from TOML content. -pub async fn install_hand( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - let toml_content = body["toml_content"].as_str().unwrap_or(""); - let skill_content = body["skill_content"].as_str().unwrap_or(""); - - if toml_content.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Missing toml_content field"})), - ); - } - - match state - .kernel - .hand_registry - .install_from_content(toml_content, skill_content) - { - Ok(def) => ( - StatusCode::OK, - Json(serde_json::json!({ - "id": def.id, - "name": def.name, - "description": def.description, - "category": format!("{:?}", def.category), - })), - ), - Err(e) => ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": format!("{e}")})), - ), - } -} - -/// POST /api/hands/{hand_id}/activate — Activate a hand (spawns agent). -pub async fn activate_hand( - State(state): State>, - Path(hand_id): Path, - body: Option>, -) -> impl IntoResponse { - let config = body.map(|b| b.0.config).unwrap_or_default(); - - match state.kernel.activate_hand(&hand_id, config) { - Ok(instance) => { - // If the hand agent has a non-reactive schedule (autonomous hands), - // start its background loop so it begins running immediately. - if let Some(agent_id) = instance.agent_id { - let entry = state.kernel.registry.list().into_iter().find(|e| e.id == agent_id); - if let Some(entry) = entry { - if !matches!( - entry.manifest.schedule, - openfang_types::agent::ScheduleMode::Reactive - ) { - state.kernel.start_background_for_agent( - agent_id, - &entry.name, - &entry.manifest.schedule, - ); - } - } - } - ( - StatusCode::OK, - Json(serde_json::json!({ - "instance_id": instance.instance_id, - "hand_id": instance.hand_id, - "status": format!("{}", instance.status), - "agent_id": instance.agent_id.map(|a| a.to_string()), - "agent_name": instance.agent_name, - "activated_at": instance.activated_at.to_rfc3339(), - })), - ) - } - Err(e) => ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": format!("{e}")})), - ), - } -} - -/// POST /api/hands/instances/{id}/pause — Pause a hand instance. -pub async fn pause_hand( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - match state.kernel.pause_hand(id) { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "paused", "instance_id": id})), - ), - Err(e) => ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": format!("{e}")})), - ), - } -} - -/// POST /api/hands/instances/{id}/resume — Resume a paused hand instance. -pub async fn resume_hand( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - match state.kernel.resume_hand(id) { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "resumed", "instance_id": id})), - ), - Err(e) => ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": format!("{e}")})), - ), - } -} - -/// DELETE /api/hands/instances/{id} — Deactivate a hand (kills agent). -pub async fn deactivate_hand( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - match state.kernel.deactivate_hand(id) { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "deactivated", "instance_id": id})), - ), - Err(e) => ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": format!("{e}")})), - ), - } -} - -/// GET /api/hands/{hand_id}/settings — Get settings schema and current values for a hand. -pub async fn get_hand_settings( - State(state): State>, - Path(hand_id): Path, -) -> impl IntoResponse { - let settings_status = match state - .kernel - .hand_registry - .check_settings_availability(&hand_id) - { - Ok(s) => s, - Err(_) => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("Hand not found: {hand_id}")})), - ); - } - }; - - // Find active instance config values (if any) - let instance_config: std::collections::HashMap = state - .kernel - .hand_registry - .list_instances() - .iter() - .find(|i| i.hand_id == hand_id) - .map(|i| i.config.clone()) - .unwrap_or_default(); - - ( - StatusCode::OK, - Json(serde_json::json!({ - "hand_id": hand_id, - "settings": settings_status, - "current_values": instance_config, - })), - ) -} - -/// PUT /api/hands/{hand_id}/settings — Update settings for a hand instance. -pub async fn update_hand_settings( - State(state): State>, - Path(hand_id): Path, - Json(config): Json>, -) -> impl IntoResponse { - // Find active instance for this hand - let instance_id = state - .kernel - .hand_registry - .list_instances() - .iter() - .find(|i| i.hand_id == hand_id) - .map(|i| i.instance_id); - - match instance_id { - Some(id) => match state.kernel.hand_registry.update_config(id, config.clone()) { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "ok", - "hand_id": hand_id, - "instance_id": id, - "config": config, - })), - ), - Err(e) => ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": format!("{e}")})), - ), - }, - None => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("No active instance for hand: {hand_id}. Activate the hand first.")})), - ), - } -} - -/// GET /api/hands/instances/{id}/stats — Get dashboard stats for a hand instance. -pub async fn hand_stats( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let instance = match state.kernel.hand_registry.get_instance(id) { - Some(i) => i, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Instance not found"})), - ); - } - }; - - let def = match state.kernel.hand_registry.get_definition(&instance.hand_id) { - Some(d) => d, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Hand definition not found"})), - ); - } - }; - - let agent_id = match instance.agent_id { - Some(aid) => aid, - None => { - return ( - StatusCode::OK, - Json(serde_json::json!({ - "instance_id": id, - "hand_id": instance.hand_id, - "metrics": {}, - })), - ); - } - }; - - // Read dashboard metrics from agent's structured memory - let mut metrics = serde_json::Map::new(); - for metric in &def.dashboard.metrics { - let value = state - .kernel - .memory - .structured_get(agent_id, &metric.memory_key) - .ok() - .flatten() - .unwrap_or(serde_json::Value::Null); - metrics.insert( - metric.label.clone(), - serde_json::json!({ - "value": value, - "format": metric.format, - }), - ); - } - - ( - StatusCode::OK, - Json(serde_json::json!({ - "instance_id": id, - "hand_id": instance.hand_id, - "status": format!("{}", instance.status), - "agent_id": agent_id.to_string(), - "metrics": metrics, - })), - ) -} - -/// GET /api/hands/instances/{id}/browser — Get live browser state for a hand instance. -pub async fn hand_instance_browser( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - // 1. Look up instance - let instance = match state.kernel.hand_registry.get_instance(id) { - Some(i) => i, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Instance not found"})), - ); - } - }; - - // 2. Get agent_id - let agent_id = match instance.agent_id { - Some(aid) => aid, - None => { - return (StatusCode::OK, Json(serde_json::json!({"active": false}))); - } - }; - - let agent_id_str = agent_id.to_string(); - - // 3. Check if a browser session exists (without creating one) - if !state.kernel.browser_ctx.has_session(&agent_id_str) { - return (StatusCode::OK, Json(serde_json::json!({"active": false}))); - } - - // 4. Send ReadPage command to get page info - let mut url = String::new(); - let mut title = String::new(); - let mut content = String::new(); - - match state - .kernel - .browser_ctx - .send_command( - &agent_id_str, - openfang_runtime::browser::BrowserCommand::ReadPage, - ) - .await - { - Ok(resp) if resp.success => { - if let Some(data) = &resp.data { - url = data["url"].as_str().unwrap_or("").to_string(); - title = data["title"].as_str().unwrap_or("").to_string(); - content = data["content"].as_str().unwrap_or("").to_string(); - // Truncate content to avoid huge payloads (UTF-8 safe) - if content.len() > 2000 { - content = format!("{}... (truncated)", openfang_types::truncate_str(&content, 2000)); - } - } - } - Ok(_) => {} // Non-success: leave defaults - Err(_) => {} // Error: leave defaults - } - - // 5. Send Screenshot command to get visual state - let mut screenshot_base64 = String::new(); - - match state - .kernel - .browser_ctx - .send_command( - &agent_id_str, - openfang_runtime::browser::BrowserCommand::Screenshot, - ) - .await - { - Ok(resp) if resp.success => { - if let Some(data) = &resp.data { - screenshot_base64 = data["image_base64"].as_str().unwrap_or("").to_string(); - } - } - Ok(_) => {} - Err(_) => {} - } - - // 6. Return combined state - ( - StatusCode::OK, - Json(serde_json::json!({ - "active": true, - "url": url, - "title": title, - "content": content, - "screenshot_base64": screenshot_base64, - })), - ) -} - -// --------------------------------------------------------------------------- -// MCP server endpoints -// --------------------------------------------------------------------------- - -/// GET /api/mcp/servers — List configured MCP servers and their tools. -pub async fn list_mcp_servers(State(state): State>) -> impl IntoResponse { - // Get configured servers from config - let config_servers: Vec = state - .kernel - .config - .mcp_servers - .iter() - .map(|s| { - let transport = match &s.transport { - openfang_types::config::McpTransportEntry::Stdio { command, args } => { - serde_json::json!({ - "type": "stdio", - "command": command, - "args": args, - }) - } - openfang_types::config::McpTransportEntry::Sse { url } => { - serde_json::json!({ - "type": "sse", - "url": url, - }) - } - }; - serde_json::json!({ - "name": s.name, - "transport": transport, - "timeout_secs": s.timeout_secs, - "env": s.env, - }) - }) - .collect(); - - // Get connected servers and their tools from the live MCP connections - let connections = state.kernel.mcp_connections.lock().await; - let connected: Vec = connections - .iter() - .map(|conn| { - let tools: Vec = conn - .tools() - .iter() - .map(|t| { - serde_json::json!({ - "name": t.name, - "description": t.description, - }) - }) - .collect(); - serde_json::json!({ - "name": conn.name(), - "tools_count": tools.len(), - "tools": tools, - "connected": true, - }) - }) - .collect(); - - Json(serde_json::json!({ - "configured": config_servers, - "connected": connected, - "total_configured": config_servers.len(), - "total_connected": connected.len(), - })) -} - -// --------------------------------------------------------------------------- -// Audit endpoints -// --------------------------------------------------------------------------- - -/// GET /api/audit/recent — Get recent audit log entries. -pub async fn audit_recent( - State(state): State>, - Query(params): Query>, -) -> impl IntoResponse { - let n: usize = params - .get("n") - .and_then(|v| v.parse().ok()) - .unwrap_or(50) - .min(1000); // Cap at 1000 - - let entries = state.kernel.audit_log.recent(n); - let tip = state.kernel.audit_log.tip_hash(); - - let items: Vec = entries - .iter() - .map(|e| { - serde_json::json!({ - "seq": e.seq, - "timestamp": e.timestamp, - "agent_id": e.agent_id, - "action": format!("{:?}", e.action), - "detail": e.detail, - "outcome": e.outcome, - "hash": e.hash, - }) - }) - .collect(); - - Json(serde_json::json!({ - "entries": items, - "total": state.kernel.audit_log.len(), - "tip_hash": tip, - })) -} - -/// GET /api/audit/verify — Verify the audit chain integrity. -pub async fn audit_verify(State(state): State>) -> impl IntoResponse { - let entry_count = state.kernel.audit_log.len(); - match state.kernel.audit_log.verify_integrity() { - Ok(()) => { - if entry_count == 0 { - // SECURITY: Warn that an empty audit log has no forensic value - Json(serde_json::json!({ - "valid": true, - "entries": 0, - "warning": "Audit log is empty — no events have been recorded yet", - "tip_hash": state.kernel.audit_log.tip_hash(), - })) - } else { - Json(serde_json::json!({ - "valid": true, - "entries": entry_count, - "tip_hash": state.kernel.audit_log.tip_hash(), - })) - } - } - Err(msg) => Json(serde_json::json!({ - "valid": false, - "error": msg, - "entries": entry_count, - })), - } -} - -/// GET /api/logs/stream — SSE endpoint for real-time audit log streaming. -/// -/// Streams new audit entries as Server-Sent Events. Accepts optional query -/// parameters for filtering: -/// - `level` — filter by classified level (info, warn, error) -/// - `filter` — text substring filter across action/detail/agent_id -/// - `token` — auth token (for EventSource clients that cannot set headers) -/// -/// A heartbeat ping is sent every 15 seconds to keep the connection alive. -/// The endpoint polls the audit log every second and sends only new entries -/// (tracked by sequence number). On first connect, existing entries are sent -/// as a backfill so the client has immediate context. -pub async fn logs_stream( - State(state): State>, - Query(params): Query>, -) -> axum::response::Response { - use axum::response::sse::{Event, KeepAlive, Sse}; - - let level_filter = params.get("level").cloned().unwrap_or_default(); - let text_filter = params - .get("filter") - .cloned() - .unwrap_or_default() - .to_lowercase(); - - let (tx, rx) = tokio::sync::mpsc::channel::< - Result, - >(256); - - tokio::spawn(async move { - let mut last_seq: u64 = 0; - let mut first_poll = true; - - loop { - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - - let entries = state.kernel.audit_log.recent(200); - - for entry in &entries { - // On first poll, send all existing entries as backfill. - // After that, only send entries newer than last_seq. - if !first_poll && entry.seq <= last_seq { - continue; - } - - let action_str = format!("{:?}", entry.action); - - // Apply level filter - if !level_filter.is_empty() { - let classified = classify_audit_level(&action_str); - if classified != level_filter { - continue; - } - } - - // Apply text filter - if !text_filter.is_empty() { - let haystack = format!("{} {} {}", action_str, entry.detail, entry.agent_id) - .to_lowercase(); - if !haystack.contains(&text_filter) { - continue; - } - } - - let json = serde_json::json!({ - "seq": entry.seq, - "timestamp": entry.timestamp, - "agent_id": entry.agent_id, - "action": action_str, - "detail": entry.detail, - "outcome": entry.outcome, - "hash": entry.hash, - }); - let data = serde_json::to_string(&json).unwrap_or_default(); - if tx.send(Ok(Event::default().data(data))).await.is_err() { - return; // Client disconnected - } - } - - // Update tracking state - if let Some(last) = entries.last() { - last_seq = last.seq; - } - first_poll = false; - } - }); - - let rx_stream = tokio_stream::wrappers::ReceiverStream::new(rx); - Sse::new(rx_stream) - .keep_alive( - KeepAlive::new() - .interval(std::time::Duration::from_secs(15)) - .text("ping"), - ) - .into_response() -} - -/// Classify an audit action string into a level (info, warn, error). -fn classify_audit_level(action: &str) -> &'static str { - let a = action.to_lowercase(); - if a.contains("error") || a.contains("fail") || a.contains("crash") || a.contains("denied") { - "error" - } else if a.contains("warn") || a.contains("block") || a.contains("kill") { - "warn" - } else { - "info" - } -} - -// --------------------------------------------------------------------------- -// Peer endpoints -// --------------------------------------------------------------------------- - -/// GET /api/peers — List known OFP peers. -pub async fn list_peers(State(state): State>) -> impl IntoResponse { - // Peers are tracked in the wire module's PeerRegistry. - // The kernel doesn't directly hold a PeerRegistry, so we return an empty list - // unless one is available. The API server can be extended to inject a registry. - if let Some(ref peer_registry) = state.peer_registry { - let peers: Vec = peer_registry - .all_peers() - .iter() - .map(|p| { - serde_json::json!({ - "node_id": p.node_id, - "node_name": p.node_name, - "address": p.address.to_string(), - "state": format!("{:?}", p.state), - "agents": p.agents.iter().map(|a| serde_json::json!({ - "id": a.id, - "name": a.name, - })).collect::>(), - "connected_at": p.connected_at.to_rfc3339(), - "protocol_version": p.protocol_version, - }) - }) - .collect(); - Json(serde_json::json!({"peers": peers, "total": peers.len()})) - } else { - Json(serde_json::json!({"peers": [], "total": 0})) - } -} - -/// GET /api/network/status — OFP network status summary. -pub async fn network_status(State(state): State>) -> impl IntoResponse { - let enabled = state.kernel.config.network_enabled - && !state.kernel.config.network.shared_secret.is_empty(); - - let (node_id, listen_address, connected_peers, total_peers) = - if let Some(ref peer_node) = state.kernel.peer_node { - let registry = peer_node.registry(); - ( - peer_node.node_id().to_string(), - peer_node.local_addr().to_string(), - registry.connected_count(), - registry.total_count(), - ) - } else { - (String::new(), String::new(), 0, 0) - }; - - Json(serde_json::json!({ - "enabled": enabled, - "node_id": node_id, - "listen_address": listen_address, - "connected_peers": connected_peers, - "total_peers": total_peers, - })) -} - -// --------------------------------------------------------------------------- -// Tools endpoint -// --------------------------------------------------------------------------- - -/// GET /api/tools — List all tool definitions (built-in + MCP). -pub async fn list_tools(State(state): State>) -> impl IntoResponse { - let mut tools: Vec = builtin_tool_definitions() - .iter() - .map(|t| { - serde_json::json!({ - "name": t.name, - "description": t.description, - "input_schema": t.input_schema, - }) - }) - .collect(); - - // Include MCP tools so they're visible in Settings -> Tools - if let Ok(mcp_tools) = state.kernel.mcp_tools.lock() { - for t in mcp_tools.iter() { - tools.push(serde_json::json!({ - "name": t.name, - "description": t.description, - "input_schema": t.input_schema, - "source": "mcp", - })); - } - } - - Json(serde_json::json!({"tools": tools, "total": tools.len()})) -} - -// --------------------------------------------------------------------------- -// Config endpoint -// --------------------------------------------------------------------------- - -/// GET /api/config — Get kernel configuration (secrets redacted). -pub async fn get_config(State(state): State>) -> impl IntoResponse { - // Return a redacted view of the kernel config - let config = &state.kernel.config; - Json(serde_json::json!({ - "home_dir": config.home_dir.to_string_lossy(), - "data_dir": config.data_dir.to_string_lossy(), - "api_key": if config.api_key.is_empty() { "not set" } else { "***" }, - "default_model": { - "provider": config.default_model.provider, - "model": config.default_model.model, - "api_key_env": config.default_model.api_key_env, - }, - "memory": { - "decay_rate": config.memory.decay_rate, - }, - })) -} - -// --------------------------------------------------------------------------- -// Usage endpoint -// --------------------------------------------------------------------------- - -/// GET /api/usage — Get per-agent usage statistics. -pub async fn usage_stats(State(state): State>) -> impl IntoResponse { - let agents: Vec = state - .kernel - .registry - .list() - .iter() - .map(|e| { - let (tokens, tool_calls) = state.kernel.scheduler.get_usage(e.id).unwrap_or((0, 0)); - serde_json::json!({ - "agent_id": e.id.to_string(), - "name": e.name, - "total_tokens": tokens, - "tool_calls": tool_calls, - }) - }) - .collect(); - - Json(serde_json::json!({"agents": agents})) -} - -// --------------------------------------------------------------------------- -// Usage summary endpoints -// --------------------------------------------------------------------------- - -/// GET /api/usage/summary — Get overall usage summary from UsageStore. -pub async fn usage_summary(State(state): State>) -> impl IntoResponse { - match state.kernel.memory.usage().query_summary(None) { - Ok(s) => Json(serde_json::json!({ - "total_input_tokens": s.total_input_tokens, - "total_output_tokens": s.total_output_tokens, - "total_cost_usd": s.total_cost_usd, - "call_count": s.call_count, - "total_tool_calls": s.total_tool_calls, - })), - Err(_) => Json(serde_json::json!({ - "total_input_tokens": 0, - "total_output_tokens": 0, - "total_cost_usd": 0.0, - "call_count": 0, - "total_tool_calls": 0, - })), - } -} - -/// GET /api/usage/by-model — Get usage grouped by model. -pub async fn usage_by_model(State(state): State>) -> impl IntoResponse { - match state.kernel.memory.usage().query_by_model() { - Ok(models) => { - let list: Vec = models - .iter() - .map(|m| { - serde_json::json!({ - "model": m.model, - "total_cost_usd": m.total_cost_usd, - "total_input_tokens": m.total_input_tokens, - "total_output_tokens": m.total_output_tokens, - "call_count": m.call_count, - }) - }) - .collect(); - Json(serde_json::json!({"models": list})) - } - Err(_) => Json(serde_json::json!({"models": []})), - } -} - -/// GET /api/usage/daily — Get daily usage breakdown for the last 7 days. -pub async fn usage_daily(State(state): State>) -> impl IntoResponse { - let days = state.kernel.memory.usage().query_daily_breakdown(7); - let today_cost = state.kernel.memory.usage().query_today_cost(); - let first_event = state.kernel.memory.usage().query_first_event_date(); - - let days_list = match days { - Ok(d) => d - .iter() - .map(|day| { - serde_json::json!({ - "date": day.date, - "cost_usd": day.cost_usd, - "tokens": day.tokens, - "calls": day.calls, - }) - }) - .collect::>(), - Err(_) => vec![], - }; - - Json(serde_json::json!({ - "days": days_list, - "today_cost_usd": today_cost.unwrap_or(0.0), - "first_event_date": first_event.unwrap_or(None), - })) -} - -// --------------------------------------------------------------------------- -// Budget endpoints -// --------------------------------------------------------------------------- - -/// GET /api/budget — Current budget status (limits, spend, % used). -pub async fn budget_status(State(state): State>) -> impl IntoResponse { - let status = state - .kernel - .metering - .budget_status(&state.kernel.config.budget); - Json(serde_json::to_value(&status).unwrap_or_default()) -} - -/// PUT /api/budget — Update global budget limits (in-memory only, not persisted to config.toml). -pub async fn update_budget( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - // SAFETY: Budget config is updated in-place. Since KernelConfig is behind - // an Arc and we only have &self, we use ptr mutation (same pattern as OFP). - let config_ptr = &state.kernel.config as *const openfang_types::config::KernelConfig - as *mut openfang_types::config::KernelConfig; - - // Apply updates - unsafe { - if let Some(v) = body["max_hourly_usd"].as_f64() { - (*config_ptr).budget.max_hourly_usd = v; - } - if let Some(v) = body["max_daily_usd"].as_f64() { - (*config_ptr).budget.max_daily_usd = v; - } - if let Some(v) = body["max_monthly_usd"].as_f64() { - (*config_ptr).budget.max_monthly_usd = v; - } - if let Some(v) = body["alert_threshold"].as_f64() { - (*config_ptr).budget.alert_threshold = v.clamp(0.0, 1.0); - } - } - - let status = state - .kernel - .metering - .budget_status(&state.kernel.config.budget); - Json(serde_json::to_value(&status).unwrap_or_default()) -} - -/// GET /api/budget/agents/{id} — Per-agent budget/quota status. -pub async fn agent_budget_status( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ) - } - }; - - let entry = match state.kernel.registry.get(agent_id) { - Some(e) => e, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ) - } - }; - - let quota = &entry.manifest.resources; - let usage_store = openfang_memory::usage::UsageStore::new(state.kernel.memory.usage_conn()); - let hourly = usage_store.query_hourly(agent_id).unwrap_or(0.0); - let daily = usage_store.query_daily(agent_id).unwrap_or(0.0); - let monthly = usage_store.query_monthly(agent_id).unwrap_or(0.0); - - ( - StatusCode::OK, - Json(serde_json::json!({ - "agent_id": agent_id.to_string(), - "agent_name": entry.name, - "hourly": { - "spend": hourly, - "limit": quota.max_cost_per_hour_usd, - "pct": if quota.max_cost_per_hour_usd > 0.0 { hourly / quota.max_cost_per_hour_usd } else { 0.0 }, - }, - "daily": { - "spend": daily, - "limit": quota.max_cost_per_day_usd, - "pct": if quota.max_cost_per_day_usd > 0.0 { daily / quota.max_cost_per_day_usd } else { 0.0 }, - }, - "monthly": { - "spend": monthly, - "limit": quota.max_cost_per_month_usd, - "pct": if quota.max_cost_per_month_usd > 0.0 { monthly / quota.max_cost_per_month_usd } else { 0.0 }, - }, - })), - ) -} - -/// GET /api/budget/agents — Per-agent cost ranking (top spenders). -pub async fn agent_budget_ranking(State(state): State>) -> impl IntoResponse { - let usage_store = openfang_memory::usage::UsageStore::new(state.kernel.memory.usage_conn()); - let agents: Vec = state - .kernel - .registry - .list() - .iter() - .filter_map(|entry| { - let daily = usage_store.query_daily(entry.id).unwrap_or(0.0); - if daily > 0.0 { - Some(serde_json::json!({ - "agent_id": entry.id.to_string(), - "name": entry.name, - "daily_cost_usd": daily, - "hourly_limit": entry.manifest.resources.max_cost_per_hour_usd, - "daily_limit": entry.manifest.resources.max_cost_per_day_usd, - "monthly_limit": entry.manifest.resources.max_cost_per_month_usd, - })) - } else { - None - } - }) - .collect(); - - Json(serde_json::json!({"agents": agents, "total": agents.len()})) -} - -/// PUT /api/budget/agents/{id} — Update per-agent budget limits at runtime. -pub async fn update_agent_budget( - State(state): State>, - Path(id): Path, - Json(body): Json, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ) - } - }; - - let hourly = body["max_cost_per_hour_usd"].as_f64(); - let daily = body["max_cost_per_day_usd"].as_f64(); - let monthly = body["max_cost_per_month_usd"].as_f64(); - - if hourly.is_none() && daily.is_none() && monthly.is_none() { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Provide at least one of: max_cost_per_hour_usd, max_cost_per_day_usd, max_cost_per_month_usd"})), - ); - } - - match state - .kernel - .registry - .update_resources(agent_id, hourly, daily, monthly) - { - Ok(()) => { - // Persist updated entry - if let Some(entry) = state.kernel.registry.get(agent_id) { - let _ = state.kernel.memory.save_agent(&entry); - } - ( - StatusCode::OK, - Json(serde_json::json!({"status": "ok", "message": "Agent budget updated"})), - ) - } - Err(e) => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("{e}")})), - ), - } -} - -// --------------------------------------------------------------------------- -// Session listing endpoints -// --------------------------------------------------------------------------- - -/// GET /api/sessions — List all sessions with metadata. -pub async fn list_sessions(State(state): State>) -> impl IntoResponse { - match state.kernel.memory.list_sessions() { - Ok(sessions) => Json(serde_json::json!({"sessions": sessions})), - Err(_) => Json(serde_json::json!({"sessions": []})), - } -} - -/// DELETE /api/sessions/:id — Delete a session. -pub async fn delete_session( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let session_id = match id.parse::() { - Ok(u) => openfang_types::agent::SessionId(u), - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid session ID"})), - ); - } - }; - - match state.kernel.memory.delete_session(session_id) { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "deleted", "session_id": id})), - ), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": e.to_string()})), - ), - } -} - -/// PUT /api/sessions/:id/label — Set a session label. -pub async fn set_session_label( - State(state): State>, - Path(id): Path, - Json(req): Json, -) -> impl IntoResponse { - let session_id = match id.parse::() { - Ok(u) => openfang_types::agent::SessionId(u), - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid session ID"})), - ); - } - }; - - let label = req.get("label").and_then(|v| v.as_str()); - - // Validate label if present - if let Some(lbl) = label { - if let Err(e) = openfang_types::agent::SessionLabel::new(lbl) { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": e.to_string()})), - ); - } - } - - match state.kernel.memory.set_session_label(session_id, label) { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "updated", - "session_id": id, - "label": label, - })), - ), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": e.to_string()})), - ), - } -} - -/// GET /api/sessions/by-label/:label — Find session by label (scoped to agent). -pub async fn find_session_by_label( - State(state): State>, - Path((agent_id_str, label)): Path<(String, String)>, -) -> impl IntoResponse { - let agent_id = match agent_id_str.parse::() { - Ok(u) => openfang_types::agent::AgentId(u), - Err(_) => { - // Try name lookup - match state.kernel.registry.find_by_name(&agent_id_str) { - Some(entry) => entry.id, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ); - } - } - } - }; - - match state.kernel.memory.find_session_by_label(agent_id, &label) { - Ok(Some(session)) => ( - StatusCode::OK, - Json(serde_json::json!({ - "session_id": session.id.0.to_string(), - "agent_id": session.agent_id.0.to_string(), - "label": session.label, - "message_count": session.messages.len(), - })), - ), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "No session found with that label"})), - ), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": e.to_string()})), - ), - } -} - -// --------------------------------------------------------------------------- -// Trigger update endpoint -// --------------------------------------------------------------------------- - -/// PUT /api/triggers/:id — Update a trigger (enable/disable toggle). -pub async fn update_trigger( - State(state): State>, - Path(id): Path, - Json(req): Json, -) -> impl IntoResponse { - let trigger_id = TriggerId(match id.parse() { - Ok(u) => u, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid trigger ID"})), - ); - } - }); - - if let Some(enabled) = req.get("enabled").and_then(|v| v.as_bool()) { - if state.kernel.set_trigger_enabled(trigger_id, enabled) { - ( - StatusCode::OK, - Json( - serde_json::json!({"status": "updated", "trigger_id": id, "enabled": enabled}), - ), - ) - } else { - ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Trigger not found"})), - ) - } - } else { - ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Missing 'enabled' field"})), - ) - } -} - -// --------------------------------------------------------------------------- -// Agent update endpoint -// --------------------------------------------------------------------------- - -/// PUT /api/agents/:id — Update an agent (currently: re-set manifest fields). -pub async fn update_agent( - State(state): State>, - Path(id): Path, - Json(req): Json, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ); - } - }; - - if state.kernel.registry.get(agent_id).is_none() { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ); - } - - // Parse the new manifest - let _manifest: AgentManifest = match toml::from_str(&req.manifest_toml) { - Ok(m) => m, - Err(e) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": format!("Invalid manifest: {e}")})), - ); - } - }; - - // Note: Full manifest update requires kill + respawn. For now, acknowledge receipt. - ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "acknowledged", - "agent_id": id, - "note": "Full manifest update requires agent restart. Use DELETE + POST to apply.", - })), - ) -} - -// --------------------------------------------------------------------------- -// Migration endpoint -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Security dashboard endpoint -// --------------------------------------------------------------------------- - -/// GET /api/security — Security feature status for the dashboard. -pub async fn security_status(State(state): State>) -> impl IntoResponse { - let auth_mode = if state.kernel.config.api_key.is_empty() { - "localhost_only" - } else { - "bearer_token" - }; - - let audit_count = state.kernel.audit_log.len(); - - Json(serde_json::json!({ - "core_protections": { - "path_traversal": true, - "ssrf_protection": true, - "capability_system": true, - "privilege_escalation_prevention": true, - "subprocess_isolation": true, - "security_headers": true, - "wire_hmac_auth": true, - "request_id_tracking": true - }, - "configurable": { - "rate_limiter": { - "enabled": true, - "tokens_per_minute": 500, - "algorithm": "GCRA" - }, - "websocket_limits": { - "max_per_ip": 5, - "idle_timeout_secs": 1800, - "max_message_size": 65536, - "max_messages_per_minute": 10 - }, - "wasm_sandbox": { - "fuel_metering": true, - "epoch_interruption": true, - "default_timeout_secs": 30, - "default_fuel_limit": 1_000_000u64 - }, - "auth": { - "mode": auth_mode, - "api_key_set": !state.kernel.config.api_key.is_empty() - } - }, - "monitoring": { - "audit_trail": { - "enabled": true, - "algorithm": "SHA-256 Merkle Chain", - "entry_count": audit_count - }, - "taint_tracking": { - "enabled": true, - "tracked_labels": [ - "ExternalNetwork", - "UserInput", - "PII", - "Secret", - "UntrustedAgent" - ] - }, - "manifest_signing": { - "algorithm": "Ed25519", - "available": true - } - }, - "secret_zeroization": true, - "total_features": 15 - })) -} - -/// GET /api/migrate/detect — Auto-detect OpenClaw installation. -pub async fn migrate_detect() -> impl IntoResponse { - match openfang_migrate::openclaw::detect_openclaw_home() { - Some(path) => { - let scan = openfang_migrate::openclaw::scan_openclaw_workspace(&path); - ( - StatusCode::OK, - Json(serde_json::json!({ - "detected": true, - "path": path.display().to_string(), - "scan": scan, - })), - ) - } - None => ( - StatusCode::OK, - Json(serde_json::json!({ - "detected": false, - "path": null, - "scan": null, - })), - ), - } -} - -/// POST /api/migrate/scan — Scan a specific directory for OpenClaw workspace. -pub async fn migrate_scan(Json(req): Json) -> impl IntoResponse { - let path = std::path::PathBuf::from(&req.path); - if !path.exists() { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Directory not found"})), - ); - } - let scan = openfang_migrate::openclaw::scan_openclaw_workspace(&path); - (StatusCode::OK, Json(serde_json::json!(scan))) -} - -/// POST /api/migrate — Run migration from another agent framework. -pub async fn run_migrate(Json(req): Json) -> impl IntoResponse { - let source = match req.source.as_str() { - "openclaw" => openfang_migrate::MigrateSource::OpenClaw, - "langchain" => openfang_migrate::MigrateSource::LangChain, - "autogpt" => openfang_migrate::MigrateSource::AutoGpt, - other => { - return ( - StatusCode::BAD_REQUEST, - Json( - serde_json::json!({"error": format!("Unknown source: {other}. Use 'openclaw', 'langchain', or 'autogpt'")}), - ), - ); - } - }; - - let options = openfang_migrate::MigrateOptions { - source, - source_dir: std::path::PathBuf::from(&req.source_dir), - target_dir: std::path::PathBuf::from(&req.target_dir), - dry_run: req.dry_run, - }; - - match openfang_migrate::run_migration(&options) { - Ok(report) => { - let imported: Vec = report - .imported - .iter() - .map(|i| { - serde_json::json!({ - "kind": format!("{}", i.kind), - "name": i.name, - "destination": i.destination, - }) - }) - .collect(); - - let skipped: Vec = report - .skipped - .iter() - .map(|s| { - serde_json::json!({ - "kind": format!("{}", s.kind), - "name": s.name, - "reason": s.reason, - }) - }) - .collect(); - - ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "completed", - "dry_run": req.dry_run, - "imported": imported, - "imported_count": imported.len(), - "skipped": skipped, - "skipped_count": skipped.len(), - "warnings": report.warnings, - "report_markdown": report.to_markdown(), - })), - ) - } - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Migration failed: {e}")})), - ), - } -} - -// ── Model Catalog Endpoints ───────────────────────────────────────── - -/// GET /api/models — List all models in the catalog. -/// -/// Query parameters: -/// - `provider` — filter by provider (e.g. `?provider=anthropic`) -/// - `tier` — filter by tier (e.g. `?tier=smart`) -/// - `available` — only show models from configured providers (`?available=true`) -pub async fn list_models( - State(state): State>, - Query(params): Query>, -) -> impl IntoResponse { - let catalog = state - .kernel - .model_catalog - .read() - .unwrap_or_else(|e| e.into_inner()); - let provider_filter = params.get("provider").map(|s| s.to_lowercase()); - let tier_filter = params.get("tier").map(|s| s.to_lowercase()); - let available_only = params - .get("available") - .map(|v| v == "true" || v == "1") - .unwrap_or(false); - - let models: Vec = catalog - .list_models() - .iter() - .filter(|m| { - if let Some(ref p) = provider_filter { - if m.provider.to_lowercase() != *p { - return false; - } - } - if let Some(ref t) = tier_filter { - if m.tier.to_string() != *t { - return false; - } - } - if available_only { - let provider = catalog.get_provider(&m.provider); - if let Some(p) = provider { - if p.auth_status == openfang_types::model_catalog::AuthStatus::Missing { - return false; - } - } - } - true - }) - .map(|m| { - let available = catalog - .get_provider(&m.provider) - .map(|p| p.auth_status != openfang_types::model_catalog::AuthStatus::Missing) - .unwrap_or(false); - serde_json::json!({ - "id": m.id, - "display_name": m.display_name, - "provider": m.provider, - "tier": m.tier, - "context_window": m.context_window, - "max_output_tokens": m.max_output_tokens, - "input_cost_per_m": m.input_cost_per_m, - "output_cost_per_m": m.output_cost_per_m, - "supports_tools": m.supports_tools, - "supports_vision": m.supports_vision, - "supports_streaming": m.supports_streaming, - "available": available, - }) - }) - .collect(); - - let total = catalog.list_models().len(); - let available_count = catalog.available_models().len(); - - ( - StatusCode::OK, - Json(serde_json::json!({ - "models": models, - "total": total, - "available": available_count, - })), - ) -} - -/// GET /api/models/aliases — List all alias-to-model mappings. -pub async fn list_aliases(State(state): State>) -> impl IntoResponse { - let aliases = state - .kernel - .model_catalog - .read() - .unwrap_or_else(|e| e.into_inner()) - .list_aliases() - .clone(); - let entries: Vec = aliases - .iter() - .map(|(alias, model_id)| { - serde_json::json!({ - "alias": alias, - "model_id": model_id, - }) - }) - .collect(); - - ( - StatusCode::OK, - Json(serde_json::json!({ - "aliases": entries, - "total": entries.len(), - })), - ) -} - -/// GET /api/models/{id} — Get a single model by ID or alias. -pub async fn get_model( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let catalog = state - .kernel - .model_catalog - .read() - .unwrap_or_else(|e| e.into_inner()); - match catalog.find_model(&id) { - Some(m) => { - let available = catalog - .get_provider(&m.provider) - .map(|p| p.auth_status != openfang_types::model_catalog::AuthStatus::Missing) - .unwrap_or(false); - ( - StatusCode::OK, - Json(serde_json::json!({ - "id": m.id, - "display_name": m.display_name, - "provider": m.provider, - "tier": m.tier, - "context_window": m.context_window, - "max_output_tokens": m.max_output_tokens, - "input_cost_per_m": m.input_cost_per_m, - "output_cost_per_m": m.output_cost_per_m, - "supports_tools": m.supports_tools, - "supports_vision": m.supports_vision, - "supports_streaming": m.supports_streaming, - "aliases": m.aliases, - "available": available, - })), - ) - } - None => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("Model '{}' not found", id)})), - ), - } -} - -/// GET /api/providers — List all providers with auth status. -/// -/// For local providers (ollama, vllm, lmstudio), also probes reachability and -/// discovers available models via their health endpoints. -pub async fn list_providers(State(state): State>) -> impl IntoResponse { - let provider_list: Vec = { - let catalog = state - .kernel - .model_catalog - .read() - .unwrap_or_else(|e| e.into_inner()); - catalog.list_providers().to_vec() - }; - - let mut providers: Vec = Vec::with_capacity(provider_list.len()); - - for p in &provider_list { - let mut entry = serde_json::json!({ - "id": p.id, - "display_name": p.display_name, - "auth_status": p.auth_status, - "model_count": p.model_count, - "key_required": p.key_required, - "api_key_env": p.api_key_env, - "base_url": p.base_url, - }); - - // For local providers, add reachability info via health probe - if !p.key_required { - entry["is_local"] = serde_json::json!(true); - let probe = openfang_runtime::provider_health::probe_provider(&p.id, &p.base_url).await; - entry["reachable"] = serde_json::json!(probe.reachable); - entry["latency_ms"] = serde_json::json!(probe.latency_ms); - if !probe.discovered_models.is_empty() { - entry["discovered_models"] = serde_json::json!(probe.discovered_models); - // Merge discovered models into the catalog so agents can use them - if let Ok(mut catalog) = state.kernel.model_catalog.write() { - catalog.merge_discovered_models(&p.id, &probe.discovered_models); - } - } - if let Some(err) = &probe.error { - entry["error"] = serde_json::json!(err); - } - } - - providers.push(entry); - } - - let total = providers.len(); - ( - StatusCode::OK, - Json(serde_json::json!({ - "providers": providers, - "total": total, - })), - ) -} - -/// POST /api/models/custom — Add a custom model to the catalog. -/// -/// Persists to `~/.openfang/custom_models.json` and makes the model immediately -/// available for agent assignment. -pub async fn add_custom_model( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - let id = body - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let provider = body - .get("provider") - .and_then(|v| v.as_str()) - .unwrap_or("openrouter") - .to_string(); - let context_window = body - .get("context_window") - .and_then(|v| v.as_u64()) - .unwrap_or(128_000); - let max_output = body - .get("max_output_tokens") - .and_then(|v| v.as_u64()) - .unwrap_or(8_192); - - if id.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Missing required field: id"})), - ); - } - - let display = body - .get("display_name") - .and_then(|v| v.as_str()) - .unwrap_or(&id) - .to_string(); - - let entry = openfang_types::model_catalog::ModelCatalogEntry { - id: id.clone(), - display_name: display, - provider: provider.clone(), - tier: openfang_types::model_catalog::ModelTier::Custom, - context_window, - max_output_tokens: max_output, - input_cost_per_m: body - .get("input_cost_per_m") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0), - output_cost_per_m: body - .get("output_cost_per_m") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0), - supports_tools: body - .get("supports_tools") - .and_then(|v| v.as_bool()) - .unwrap_or(true), - supports_vision: body - .get("supports_vision") - .and_then(|v| v.as_bool()) - .unwrap_or(false), - supports_streaming: body - .get("supports_streaming") - .and_then(|v| v.as_bool()) - .unwrap_or(true), - aliases: vec![], - }; - - let mut catalog = state - .kernel - .model_catalog - .write() - .unwrap_or_else(|e| e.into_inner()); - - if !catalog.add_custom_model(entry) { - return ( - StatusCode::CONFLICT, - Json(serde_json::json!({"error": format!("Model '{}' already exists", id)})), - ); - } - - // Persist to disk - let custom_path = state.kernel.config.home_dir.join("custom_models.json"); - if let Err(e) = catalog.save_custom_models(&custom_path) { - tracing::warn!("Failed to persist custom models: {e}"); - } - - ( - StatusCode::CREATED, - Json(serde_json::json!({ - "id": id, - "provider": provider, - "status": "added" - })), - ) -} - -/// DELETE /api/models/custom/{id} — Remove a custom model. -pub async fn remove_custom_model( - State(state): State>, - axum::extract::Path(model_id): axum::extract::Path, -) -> impl IntoResponse { - let mut catalog = state - .kernel - .model_catalog - .write() - .unwrap_or_else(|e| e.into_inner()); - - if !catalog.remove_custom_model(&model_id) { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("Custom model '{}' not found", model_id)})), - ); - } - - let custom_path = state.kernel.config.home_dir.join("custom_models.json"); - if let Err(e) = catalog.save_custom_models(&custom_path) { - tracing::warn!("Failed to persist custom models: {e}"); - } - - ( - StatusCode::OK, - Json(serde_json::json!({"status": "removed"})), - ) -} - -// ── A2A (Agent-to-Agent) Protocol Endpoints ───────────────────────── - -/// GET /.well-known/agent.json — A2A Agent Card for the default agent. -pub async fn a2a_agent_card(State(state): State>) -> impl IntoResponse { - let agents = state.kernel.registry.list(); - let base_url = format!("http://{}", state.kernel.config.api_listen); - - if let Some(first) = agents.first() { - let card = openfang_runtime::a2a::build_agent_card(&first.manifest, &base_url); - ( - StatusCode::OK, - Json(serde_json::to_value(&card).unwrap_or_default()), - ) - } else { - let card = serde_json::json!({ - "name": "openfang", - "description": "OpenFang Agent OS — no agents spawned yet", - "url": format!("{base_url}/a2a"), - "version": "0.1.0", - "capabilities": { "streaming": true }, - "skills": [], - "defaultInputModes": ["text"], - "defaultOutputModes": ["text"], - }); - (StatusCode::OK, Json(card)) - } -} - -/// GET /a2a/agents — List all A2A agent cards. -pub async fn a2a_list_agents(State(state): State>) -> impl IntoResponse { - let agents = state.kernel.registry.list(); - let base_url = format!("http://{}", state.kernel.config.api_listen); - - let cards: Vec = agents - .iter() - .map(|entry| { - let card = openfang_runtime::a2a::build_agent_card(&entry.manifest, &base_url); - serde_json::to_value(&card).unwrap_or_default() - }) - .collect(); - - let total = cards.len(); - ( - StatusCode::OK, - Json(serde_json::json!({ - "agents": cards, - "total": total, - })), - ) -} - -/// POST /a2a/tasks/send — Submit a task to an agent via A2A. -pub async fn a2a_send_task( - State(state): State>, - Json(request): Json, -) -> impl IntoResponse { - // Extract message text from A2A format - let message_text = request["params"]["message"]["parts"] - .as_array() - .and_then(|parts| { - parts.iter().find_map(|p| { - if p["type"].as_str() == Some("text") { - p["text"].as_str().map(String::from) - } else { - None - } - }) - }) - .unwrap_or_else(|| "No message provided".to_string()); - - // Find target agent (use first available or specified) - let agents = state.kernel.registry.list(); - if agents.is_empty() { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "No agents available"})), - ); - } - - let agent = &agents[0]; - let task_id = uuid::Uuid::new_v4().to_string(); - let session_id = request["params"]["sessionId"].as_str().map(String::from); - - // Create the task in the store as Working - let task = openfang_runtime::a2a::A2aTask { - id: task_id.clone(), - session_id: session_id.clone(), - status: openfang_runtime::a2a::A2aTaskStatus::Working, - messages: vec![openfang_runtime::a2a::A2aMessage { - role: "user".to_string(), - parts: vec![openfang_runtime::a2a::A2aPart::Text { - text: message_text.clone(), - }], - }], - artifacts: vec![], - }; - state.kernel.a2a_task_store.insert(task); - - // Send message to agent - match state.kernel.send_message(agent.id, &message_text).await { - Ok(result) => { - let response_msg = openfang_runtime::a2a::A2aMessage { - role: "agent".to_string(), - parts: vec![openfang_runtime::a2a::A2aPart::Text { - text: result.response, - }], - }; - state - .kernel - .a2a_task_store - .complete(&task_id, response_msg, vec![]); - match state.kernel.a2a_task_store.get(&task_id) { - Some(completed_task) => ( - StatusCode::OK, - Json(serde_json::to_value(&completed_task).unwrap_or_default()), - ), - None => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "Task disappeared after completion"})), - ), - } - } - Err(e) => { - let error_msg = openfang_runtime::a2a::A2aMessage { - role: "agent".to_string(), - parts: vec![openfang_runtime::a2a::A2aPart::Text { - text: format!("Error: {e}"), - }], - }; - state.kernel.a2a_task_store.fail(&task_id, error_msg); - match state.kernel.a2a_task_store.get(&task_id) { - Some(failed_task) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::to_value(&failed_task).unwrap_or_default()), - ), - None => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Agent error: {e}")})), - ), - } - } - } -} - -/// GET /a2a/tasks/{id} — Get task status from the task store. -pub async fn a2a_get_task( - State(state): State>, - Path(task_id): Path, -) -> impl IntoResponse { - match state.kernel.a2a_task_store.get(&task_id) { - Some(task) => ( - StatusCode::OK, - Json(serde_json::to_value(&task).unwrap_or_default()), - ), - None => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("Task '{}' not found", task_id)})), - ), - } -} - -/// POST /a2a/tasks/{id}/cancel — Cancel a tracked task. -pub async fn a2a_cancel_task( - State(state): State>, - Path(task_id): Path, -) -> impl IntoResponse { - if state.kernel.a2a_task_store.cancel(&task_id) { - match state.kernel.a2a_task_store.get(&task_id) { - Some(task) => ( - StatusCode::OK, - Json(serde_json::to_value(&task).unwrap_or_default()), - ), - None => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "Task disappeared after cancellation"})), - ), - } - } else { - ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("Task '{}' not found", task_id)})), - ) - } -} - -// ── A2A Management Endpoints (outbound) ───────────────────────────────── - -/// GET /api/a2a/agents — List discovered external A2A agents. -pub async fn a2a_list_external_agents(State(state): State>) -> impl IntoResponse { - let agents = state - .kernel - .a2a_external_agents - .lock() - .unwrap_or_else(|e| e.into_inner()); - let items: Vec = agents - .iter() - .map(|(url, card)| { - serde_json::json!({ - "name": card.name, - "url": url, - "description": card.description, - "skills": card.skills, - "version": card.version, - }) - }) - .collect(); - Json(serde_json::json!({"agents": items, "total": items.len()})) -} - -/// POST /api/a2a/discover — Discover a new external A2A agent by URL. -pub async fn a2a_discover_external( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - let url = match body["url"].as_str() { - Some(u) => u.to_string(), - None => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Missing 'url' field"})), - ) - } - }; - - let client = openfang_runtime::a2a::A2aClient::new(); - match client.discover(&url).await { - Ok(card) => { - let card_json = serde_json::to_value(&card).unwrap_or_default(); - // Store in kernel's external agents list - { - let mut agents = state - .kernel - .a2a_external_agents - .lock() - .unwrap_or_else(|e| e.into_inner()); - // Update or add - if let Some(existing) = agents.iter_mut().find(|(u, _)| u == &url) { - existing.1 = card; - } else { - agents.push((url.clone(), card)); - } - } - ( - StatusCode::OK, - Json(serde_json::json!({ - "url": url, - "agent": card_json, - })), - ) - } - Err(e) => ( - StatusCode::BAD_GATEWAY, - Json(serde_json::json!({"error": e})), - ), - } -} - -/// POST /api/a2a/send — Send a task to an external A2A agent. -pub async fn a2a_send_external( - State(_state): State>, - Json(body): Json, -) -> impl IntoResponse { - let url = match body["url"].as_str() { - Some(u) => u.to_string(), - None => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Missing 'url' field"})), - ) - } - }; - let message = match body["message"].as_str() { - Some(m) => m.to_string(), - None => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Missing 'message' field"})), - ) - } - }; - let session_id = body["session_id"].as_str(); - - let client = openfang_runtime::a2a::A2aClient::new(); - match client.send_task(&url, &message, session_id).await { - Ok(task) => ( - StatusCode::OK, - Json(serde_json::to_value(&task).unwrap_or_default()), - ), - Err(e) => ( - StatusCode::BAD_GATEWAY, - Json(serde_json::json!({"error": e})), - ), - } -} - -/// GET /api/a2a/tasks/{id}/status — Get task status from an external A2A agent. -pub async fn a2a_external_task_status( - State(_state): State>, - Path(task_id): Path, - axum::extract::Query(params): axum::extract::Query>, -) -> impl IntoResponse { - let url = match params.get("url") { - Some(u) => u.clone(), - None => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Missing 'url' query parameter"})), - ) - } - }; - - let client = openfang_runtime::a2a::A2aClient::new(); - match client.get_task(&url, &task_id).await { - Ok(task) => ( - StatusCode::OK, - Json(serde_json::to_value(&task).unwrap_or_default()), - ), - Err(e) => ( - StatusCode::BAD_GATEWAY, - Json(serde_json::json!({"error": e})), - ), - } -} - -// ── MCP HTTP Endpoint ─────────────────────────────────────────────────── - -/// POST /mcp — Handle MCP JSON-RPC requests over HTTP. -/// -/// Exposes the same MCP protocol normally served via stdio, allowing -/// external MCP clients to connect over HTTP instead. -pub async fn mcp_http( - State(state): State>, - Json(request): Json, -) -> impl IntoResponse { - // Gather all available tools (builtin + skills + MCP) - let mut tools = builtin_tool_definitions(); - { - let registry = state - .kernel - .skill_registry - .read() - .unwrap_or_else(|e| e.into_inner()); - for skill_tool in registry.all_tool_definitions() { - tools.push(openfang_types::tool::ToolDefinition { - name: skill_tool.name.clone(), - description: skill_tool.description.clone(), - input_schema: skill_tool.input_schema.clone(), - }); - } - } - if let Ok(mcp_tools) = state.kernel.mcp_tools.lock() { - tools.extend(mcp_tools.iter().cloned()); - } - - // Check if this is a tools/call that needs real execution - let method = request["method"].as_str().unwrap_or(""); - if method == "tools/call" { - let tool_name = request["params"]["name"].as_str().unwrap_or(""); - let arguments = request["params"] - .get("arguments") - .cloned() - .unwrap_or(serde_json::json!({})); - - // Verify the tool exists - if !tools.iter().any(|t| t.name == tool_name) { - return Json(serde_json::json!({ - "jsonrpc": "2.0", - "id": request.get("id").cloned(), - "error": {"code": -32602, "message": format!("Unknown tool: {tool_name}")} - })); - } - - // Snapshot skill registry before async call (RwLockReadGuard is !Send) - let skill_snapshot = state - .kernel - .skill_registry - .read() - .unwrap_or_else(|e| e.into_inner()) - .snapshot(); - - // Execute the tool via the kernel's tool runner - let kernel_handle: Arc = - state.kernel.clone() as Arc; - let result = openfang_runtime::tool_runner::execute_tool( - "mcp-http", - tool_name, - &arguments, - Some(&kernel_handle), - None, - None, - Some(&skill_snapshot), - Some(&state.kernel.mcp_connections), - Some(&state.kernel.web_ctx), - Some(&state.kernel.browser_ctx), - None, - None, - Some(&state.kernel.media_engine), - None, // exec_policy - if state.kernel.config.tts.enabled { - Some(&state.kernel.tts_engine) - } else { - None - }, - if state.kernel.config.docker.enabled { - Some(&state.kernel.config.docker) - } else { - None - }, - Some(&*state.kernel.process_manager), - ) - .await; - - return Json(serde_json::json!({ - "jsonrpc": "2.0", - "id": request.get("id").cloned(), - "result": { - "content": [{"type": "text", "text": result.content}], - "isError": result.is_error, - } - })); - } - - // For non-tools/call methods (initialize, tools/list, etc.), delegate to the handler - let response = openfang_runtime::mcp_server::handle_mcp_request(&request, &tools).await; - Json(response) -} - -// ── Multi-Session Endpoints ───────────────────────────────────────────── - -/// GET /api/agents/{id}/sessions — List all sessions for an agent. -pub async fn list_agent_sessions( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ) - } - }; - match state.kernel.list_agent_sessions(agent_id) { - Ok(sessions) => ( - StatusCode::OK, - Json(serde_json::json!({"sessions": sessions})), - ), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("{e}")})), - ), - } -} - -/// POST /api/agents/{id}/sessions — Create a new session for an agent. -pub async fn create_agent_session( - State(state): State>, - Path(id): Path, - Json(req): Json, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ) - } - }; - let label = req.get("label").and_then(|v| v.as_str()); - match state.kernel.create_agent_session(agent_id, label) { - Ok(session) => (StatusCode::OK, Json(session)), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("{e}")})), - ), - } -} - -/// POST /api/agents/{id}/sessions/{session_id}/switch — Switch to an existing session. -pub async fn switch_agent_session( - State(state): State>, - Path((id, session_id_str)): Path<(String, String)>, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ) - } - }; - let session_id = match session_id_str.parse::() { - Ok(uuid) => openfang_types::agent::SessionId(uuid), - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid session ID"})), - ) - } - }; - match state.kernel.switch_agent_session(agent_id, session_id) { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "ok", "message": "Session switched"})), - ), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("{e}")})), - ), - } -} - -// ── Extended Chat Command API Endpoints ───────────────────────────────── - -/// POST /api/agents/{id}/session/reset — Reset an agent's session. -pub async fn reset_session( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ) - } - }; - match state.kernel.reset_session(agent_id) { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "ok", "message": "Session reset"})), - ), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("{e}")})), - ), - } -} - -/// DELETE /api/agents/{id}/history — Clear ALL conversation history for an agent. -pub async fn clear_agent_history( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ) - } - }; - match state.kernel.clear_agent_history(agent_id) { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "ok", "message": "All history cleared"})), - ), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("{e}")})), - ), - } -} - -/// POST /api/agents/{id}/session/compact — Trigger LLM session compaction. -pub async fn compact_session( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ) - } - }; - match state.kernel.compact_agent_session(agent_id).await { - Ok(msg) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "ok", "message": msg})), - ), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("{e}")})), - ), - } -} - -/// POST /api/agents/{id}/stop — Cancel an agent's current LLM run. -pub async fn stop_agent( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ) - } - }; - match state.kernel.stop_agent_run(agent_id) { - Ok(true) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "ok", "message": "Run cancelled"})), - ), - Ok(false) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "ok", "message": "No active run"})), - ), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("{e}")})), - ), - } -} - -/// PUT /api/agents/{id}/model — Switch an agent's model. -pub async fn set_model( - State(state): State>, - Path(id): Path, - Json(body): Json, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ) - } - }; - let model = match body["model"].as_str() { - Some(m) if !m.is_empty() => m, - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Missing 'model' field"})), - ) - } - }; - match state.kernel.set_agent_model(agent_id, model) { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "ok", "model": model})), - ), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("{e}")})), - ), - } -} - -/// GET /api/agents/{id}/tools — Get an agent's tool allowlist/blocklist. -pub async fn get_agent_tools( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ) - } - }; - let entry = match state.kernel.registry.get(agent_id) { - Some(e) => e, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ) - } - }; - ( - StatusCode::OK, - Json(serde_json::json!({ - "tool_allowlist": entry.manifest.tool_allowlist, - "tool_blocklist": entry.manifest.tool_blocklist, - })), - ) -} - -/// PUT /api/agents/{id}/tools — Update an agent's tool allowlist/blocklist. -pub async fn set_agent_tools( - State(state): State>, - Path(id): Path, - Json(body): Json, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ) - } - }; - let allowlist = body - .get("tool_allowlist") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect::>() - }); - let blocklist = body - .get("tool_blocklist") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect::>() - }); - - if allowlist.is_none() && blocklist.is_none() { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Provide 'tool_allowlist' and/or 'tool_blocklist'"})), - ); - } - - match state - .kernel - .set_agent_tool_filters(agent_id, allowlist, blocklist) - { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "ok"})), - ), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("{e}")})), - ), - } -} - -// ── Per-Agent Skill & MCP Endpoints ──────────────────────────────────── - -/// GET /api/agents/{id}/skills — Get an agent's skill assignment info. -pub async fn get_agent_skills( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ) - } - }; - let entry = match state.kernel.registry.get(agent_id) { - Some(e) => e, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ) - } - }; - let available = state - .kernel - .skill_registry - .read() - .unwrap_or_else(|e| e.into_inner()) - .skill_names(); - let mode = if entry.manifest.skills.is_empty() { - "all" - } else { - "allowlist" - }; - ( - StatusCode::OK, - Json(serde_json::json!({ - "assigned": entry.manifest.skills, - "available": available, - "mode": mode, - })), - ) -} - -/// PUT /api/agents/{id}/skills — Update an agent's skill allowlist. -pub async fn set_agent_skills( - State(state): State>, - Path(id): Path, - Json(body): Json, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ) - } - }; - let skills: Vec = body["skills"] - .as_array() - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - match state.kernel.set_agent_skills(agent_id, skills.clone()) { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "ok", "skills": skills})), - ), - Err(e) => ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": format!("{e}")})), - ), - } -} - -/// GET /api/agents/{id}/mcp_servers — Get an agent's MCP server assignment info. -pub async fn get_agent_mcp_servers( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ) - } - }; - let entry = match state.kernel.registry.get(agent_id) { - Some(e) => e, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ) - } - }; - // Collect known MCP server names from connected tools - let mut available: Vec = Vec::new(); - if let Ok(mcp_tools) = state.kernel.mcp_tools.lock() { - let mut seen = std::collections::HashSet::new(); - for tool in mcp_tools.iter() { - if let Some(server) = openfang_runtime::mcp::extract_mcp_server(&tool.name) { - if seen.insert(server.to_string()) { - available.push(server.to_string()); - } - } - } - } - let mode = if entry.manifest.mcp_servers.is_empty() { - "all" - } else { - "allowlist" - }; - ( - StatusCode::OK, - Json(serde_json::json!({ - "assigned": entry.manifest.mcp_servers, - "available": available, - "mode": mode, - })), - ) -} - -/// PUT /api/agents/{id}/mcp_servers — Update an agent's MCP server allowlist. -pub async fn set_agent_mcp_servers( - State(state): State>, - Path(id): Path, - Json(body): Json, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ) - } - }; - let servers: Vec = body["mcp_servers"] - .as_array() - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - match state - .kernel - .set_agent_mcp_servers(agent_id, servers.clone()) - { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "ok", "mcp_servers": servers})), - ), - Err(e) => ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": format!("{e}")})), - ), - } -} - -// ── Provider Key Management Endpoints ────────────────────────────────── - -/// POST /api/providers/{name}/key — Save an API key for a provider. -/// -/// SECURITY: Writes to `~/.openfang/secrets.env`, sets env var in process, -/// and refreshes auth detection. Key is zeroized after use. -pub async fn set_provider_key( - State(state): State>, - Path(name): Path, - Json(body): Json, -) -> impl IntoResponse { - // Validate provider name against known list - { - let catalog = state - .kernel - .model_catalog - .read() - .unwrap_or_else(|e| e.into_inner()); - if catalog.get_provider(&name).is_none() { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("Unknown provider '{}'", name)})), - ); - } - } - - let key = match body["key"].as_str() { - Some(k) if !k.trim().is_empty() => k.trim().to_string(), - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Missing or empty 'key' field"})), - ); - } - }; - - let env_var = { - let catalog = state - .kernel - .model_catalog - .read() - .unwrap_or_else(|e| e.into_inner()); - catalog - .get_provider(&name) - .map(|p| p.api_key_env.clone()) - .unwrap_or_default() - }; - - if env_var.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Provider does not require an API key"})), - ); - } - - // Write to secrets.env file - let secrets_path = state.kernel.config.home_dir.join("secrets.env"); - if let Err(e) = write_secret_env(&secrets_path, &env_var, &key) { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Failed to write secrets.env: {e}")})), - ); - } - - // Set env var in current process so detect_auth picks it up - std::env::set_var(&env_var, &key); - - // Refresh auth detection - state - .kernel - .model_catalog - .write() - .unwrap_or_else(|e| e.into_inner()) - .detect_auth(); - - ( - StatusCode::OK, - Json(serde_json::json!({"status": "saved", "provider": name})), - ) -} - -/// DELETE /api/providers/{name}/key — Remove an API key for a provider. -pub async fn delete_provider_key( - State(state): State>, - Path(name): Path, -) -> impl IntoResponse { - let env_var = { - let catalog = state - .kernel - .model_catalog - .read() - .unwrap_or_else(|e| e.into_inner()); - match catalog.get_provider(&name) { - Some(p) => p.api_key_env.clone(), - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("Unknown provider '{}'", name)})), - ); - } - } - }; - - if env_var.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Provider does not require an API key"})), - ); - } - - // Remove from secrets.env - let secrets_path = state.kernel.config.home_dir.join("secrets.env"); - if let Err(e) = remove_secret_env(&secrets_path, &env_var) { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Failed to update secrets.env: {e}")})), - ); - } - - // Remove from process environment - std::env::remove_var(&env_var); - - // Refresh auth detection - state - .kernel - .model_catalog - .write() - .unwrap_or_else(|e| e.into_inner()) - .detect_auth(); - - ( - StatusCode::OK, - Json(serde_json::json!({"status": "removed", "provider": name})), - ) -} - -/// POST /api/providers/{name}/test — Test a provider's connectivity. -pub async fn test_provider( - State(state): State>, - Path(name): Path, -) -> impl IntoResponse { - let (env_var, base_url, key_required, default_model) = { - let catalog = state - .kernel - .model_catalog - .read() - .unwrap_or_else(|e| e.into_inner()); - match catalog.get_provider(&name) { - Some(p) => { - // Find a default model for this provider to use in the test request - let model_id = catalog - .default_model_for_provider(&name) - .unwrap_or_default(); - ( - p.api_key_env.clone(), - p.base_url.clone(), - p.key_required, - model_id, - ) - } - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("Unknown provider '{}'", name)})), - ); - } - } - }; - - let api_key = std::env::var(&env_var).ok(); - // Only require API key for providers that need one (skip local providers like ollama/vllm/lmstudio) - if key_required && api_key.is_none() && !env_var.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Provider API key not configured"})), - ); - } - - // Attempt a lightweight connectivity test - let start = std::time::Instant::now(); - let driver_config = openfang_runtime::llm_driver::DriverConfig { - provider: name.clone(), - api_key, - base_url: if base_url.is_empty() { - None - } else { - Some(base_url) - }, - }; - - match openfang_runtime::drivers::create_driver(&driver_config) { - Ok(driver) => { - // Send a minimal completion request to test connectivity - let test_req = openfang_runtime::llm_driver::CompletionRequest { - model: default_model.clone(), - messages: vec![openfang_types::message::Message::user("Hi")], - tools: vec![], - max_tokens: 1, - temperature: 0.0, - system: None, - thinking: None, - }; - match driver.complete(test_req).await { - Ok(_) => { - let latency_ms = start.elapsed().as_millis(); - ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "ok", - "provider": name, - "latency_ms": latency_ms, - })), - ) - } - Err(e) => ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "error", - "provider": name, - "error": format!("{e}"), - })), - ), - } - } - Err(e) => ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "error", - "provider": name, - "error": format!("Failed to create driver: {e}"), - })), - ), - } -} - -/// PUT /api/providers/{name}/url — Set a custom base URL for a provider. -pub async fn set_provider_url( - State(state): State>, - Path(name): Path, - Json(body): Json, -) -> impl IntoResponse { - // Validate provider exists - let provider_exists = { - let catalog = state - .kernel - .model_catalog - .read() - .unwrap_or_else(|e| e.into_inner()); - catalog.get_provider(&name).is_some() - }; - if !provider_exists { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("Unknown provider '{}'", name)})), - ); - } - - let base_url = match body["base_url"].as_str() { - Some(u) if !u.trim().is_empty() => u.trim().to_string(), - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Missing or empty 'base_url' field"})), - ); - } - }; - - // Validate URL scheme - if !base_url.starts_with("http://") && !base_url.starts_with("https://") { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "base_url must start with http:// or https://"})), - ); - } - - // Update catalog in memory - { - let mut catalog = state - .kernel - .model_catalog - .write() - .unwrap_or_else(|e| e.into_inner()); - catalog.set_provider_url(&name, &base_url); - } - - // Persist to config.toml [provider_urls] section - let config_path = state.kernel.config.home_dir.join("config.toml"); - if let Err(e) = upsert_provider_url(&config_path, &name, &base_url) { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Failed to save config: {e}")})), - ); - } - - // Probe reachability at the new URL - let probe = - openfang_runtime::provider_health::probe_provider(&name, &base_url).await; - - // Merge discovered models into catalog - if !probe.discovered_models.is_empty() { - if let Ok(mut catalog) = state.kernel.model_catalog.write() { - catalog.merge_discovered_models(&name, &probe.discovered_models); - } - } - - let mut resp = serde_json::json!({ - "status": "saved", - "provider": name, - "base_url": base_url, - "reachable": probe.reachable, - "latency_ms": probe.latency_ms, - }); - if !probe.discovered_models.is_empty() { - resp["discovered_models"] = serde_json::json!(probe.discovered_models); - } - - (StatusCode::OK, Json(resp)) -} - -/// Upsert a provider URL in the `[provider_urls]` section of config.toml. -fn upsert_provider_url( - config_path: &std::path::Path, - provider: &str, - url: &str, -) -> Result<(), Box> { - let content = if config_path.exists() { - std::fs::read_to_string(config_path)? - } else { - String::new() - }; - - let mut doc: toml::Value = if content.trim().is_empty() { - toml::Value::Table(toml::map::Map::new()) - } else { - toml::from_str(&content)? - }; - - let root = doc.as_table_mut().ok_or("Config is not a TOML table")?; - - if !root.contains_key("provider_urls") { - root.insert( - "provider_urls".to_string(), - toml::Value::Table(toml::map::Map::new()), - ); - } - let urls_table = root - .get_mut("provider_urls") - .and_then(|v| v.as_table_mut()) - .ok_or("provider_urls is not a table")?; - - urls_table.insert(provider.to_string(), toml::Value::String(url.to_string())); - - if let Some(parent) = config_path.parent() { - std::fs::create_dir_all(parent)?; - } - - std::fs::write(config_path, toml::to_string_pretty(&doc)?)?; - Ok(()) -} - -/// POST /api/skills/create — Create a local prompt-only skill. -pub async fn create_skill( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - let name = match body["name"].as_str() { - Some(n) if !n.trim().is_empty() => n.trim().to_string(), - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Missing or empty 'name' field"})), - ); - } - }; - - // Validate name (alphanumeric + hyphens only) - if !name - .chars() - .all(|c| c.is_alphanumeric() || c == '-' || c == '_') - { - return ( - StatusCode::BAD_REQUEST, - Json( - serde_json::json!({"error": "Skill name must contain only letters, numbers, hyphens, and underscores"}), - ), - ); - } - - let description = body["description"].as_str().unwrap_or("").to_string(); - let runtime = body["runtime"].as_str().unwrap_or("prompt_only"); - let prompt_context = body["prompt_context"].as_str().unwrap_or("").to_string(); - - // Only allow prompt_only skills from the web UI for safety - if runtime != "prompt_only" { - return ( - StatusCode::BAD_REQUEST, - Json( - serde_json::json!({"error": "Only prompt_only skills can be created from the web UI"}), - ), - ); - } - - // Write skill.toml to ~/.openfang/skills/{name}/ - let skill_dir = state.kernel.config.home_dir.join("skills").join(&name); - if skill_dir.exists() { - return ( - StatusCode::CONFLICT, - Json(serde_json::json!({"error": format!("Skill '{}' already exists", name)})), - ); - } - - if let Err(e) = std::fs::create_dir_all(&skill_dir) { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Failed to create skill directory: {e}")})), - ); - } - - let toml_content = format!( - "[skill]\nname = \"{}\"\ndescription = \"{}\"\nruntime = \"prompt_only\"\n\n[prompt]\ncontext = \"\"\"\n{}\n\"\"\"\n", - name, - description.replace('"', "\\\""), - prompt_context - ); - - let toml_path = skill_dir.join("skill.toml"); - if let Err(e) = std::fs::write(&toml_path, &toml_content) { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Failed to write skill.toml: {e}")})), - ); - } - - ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "created", - "name": name, - "note": "Restart the daemon to load the new skill, or it will be available on next boot." - })), - ) -} - -// ── Helper functions for secrets.env management ──────────────────────── - -/// Write or update a key in the secrets.env file. -/// File format: one `KEY=value` per line. Existing keys are overwritten. -fn write_secret_env(path: &std::path::Path, key: &str, value: &str) -> Result<(), std::io::Error> { - let mut lines: Vec = if path.exists() { - std::fs::read_to_string(path)? - .lines() - .map(|l| l.to_string()) - .collect() - } else { - Vec::new() - }; - - // Remove existing line for this key - lines.retain(|l| !l.starts_with(&format!("{key}="))); - - // Add new line - lines.push(format!("{key}={value}")); - - // Ensure parent directory exists - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - - std::fs::write(path, lines.join("\n") + "\n")?; - - // SECURITY: Restrict file permissions on Unix - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); - } - - Ok(()) -} - -/// Remove a key from the secrets.env file. -fn remove_secret_env(path: &std::path::Path, key: &str) -> Result<(), std::io::Error> { - if !path.exists() { - return Ok(()); - } - - let lines: Vec = std::fs::read_to_string(path)? - .lines() - .filter(|l| !l.starts_with(&format!("{key}="))) - .map(|l| l.to_string()) - .collect(); - - std::fs::write(path, lines.join("\n") + "\n")?; - - Ok(()) -} - -// ── Config.toml channel management helpers ────────────────────────── - -/// Upsert a `[channels.]` section in config.toml with the given non-secret fields. -fn upsert_channel_config( - config_path: &std::path::Path, - channel_name: &str, - fields: &HashMap, -) -> Result<(), Box> { - let content = if config_path.exists() { - std::fs::read_to_string(config_path)? - } else { - String::new() - }; - - let mut doc: toml::Value = if content.trim().is_empty() { - toml::Value::Table(toml::map::Map::new()) - } else { - toml::from_str(&content)? - }; - - let root = doc.as_table_mut().ok_or("Config is not a TOML table")?; - - // Ensure [channels] table exists - if !root.contains_key("channels") { - root.insert( - "channels".to_string(), - toml::Value::Table(toml::map::Map::new()), - ); - } - let channels_table = root - .get_mut("channels") - .and_then(|v| v.as_table_mut()) - .ok_or("channels is not a table")?; - - // Build channel sub-table with correct TOML types - let mut ch_table = toml::map::Map::new(); - for (k, (v, ft)) in fields { - let toml_val = match ft { - FieldType::Number => { - if let Ok(n) = v.parse::() { - toml::Value::Integer(n) - } else { - toml::Value::String(v.clone()) - } - } - _ => toml::Value::String(v.clone()), - }; - ch_table.insert(k.clone(), toml_val); - } - channels_table.insert(channel_name.to_string(), toml::Value::Table(ch_table)); - - // Ensure parent directory exists - if let Some(parent) = config_path.parent() { - std::fs::create_dir_all(parent)?; - } - - std::fs::write(config_path, toml::to_string_pretty(&doc)?)?; - Ok(()) -} - -/// Remove a `[channels.]` section from config.toml. -fn remove_channel_config( - config_path: &std::path::Path, - channel_name: &str, -) -> Result<(), Box> { - if !config_path.exists() { - return Ok(()); - } - - let content = std::fs::read_to_string(config_path)?; - if content.trim().is_empty() { - return Ok(()); - } - - let mut doc: toml::Value = toml::from_str(&content)?; - - if let Some(channels) = doc - .as_table_mut() - .and_then(|r| r.get_mut("channels")) - .and_then(|c| c.as_table_mut()) - { - channels.remove(channel_name); - } - - std::fs::write(config_path, toml::to_string_pretty(&doc)?)?; - Ok(()) -} - -// --------------------------------------------------------------------------- -// Integration management endpoints -// --------------------------------------------------------------------------- - -/// GET /api/integrations — List installed integrations with status. -pub async fn list_integrations(State(state): State>) -> impl IntoResponse { - let registry = state - .kernel - .extension_registry - .read() - .unwrap_or_else(|e| e.into_inner()); - let health = &state.kernel.extension_health; - - let mut entries = Vec::new(); - for info in registry.list_all_info() { - let h = health.get_health(&info.template.id); - let status = match &info.installed { - Some(inst) if !inst.enabled => "disabled", - Some(_) => match h.as_ref().map(|h| &h.status) { - Some(openfang_extensions::IntegrationStatus::Ready) => "ready", - Some(openfang_extensions::IntegrationStatus::Error(_)) => "error", - _ => "installed", - }, - None => continue, // Only show installed - }; - entries.push(serde_json::json!({ - "id": info.template.id, - "name": info.template.name, - "icon": info.template.icon, - "category": info.template.category.to_string(), - "status": status, - "tool_count": h.as_ref().map(|h| h.tool_count).unwrap_or(0), - "installed_at": info.installed.as_ref().map(|i| i.installed_at.to_rfc3339()), - })); - } - - Json(serde_json::json!({ - "installed": entries, - "count": entries.len(), - })) -} - -/// GET /api/integrations/available — List all available templates. -pub async fn list_available_integrations(State(state): State>) -> impl IntoResponse { - let registry = state - .kernel - .extension_registry - .read() - .unwrap_or_else(|e| e.into_inner()); - let templates: Vec = registry - .list_templates() - .iter() - .map(|t| { - let installed = registry.is_installed(&t.id); - serde_json::json!({ - "id": t.id, - "name": t.name, - "description": t.description, - "icon": t.icon, - "category": t.category.to_string(), - "installed": installed, - "tags": t.tags, - "required_env": t.required_env.iter().map(|e| serde_json::json!({ - "name": e.name, - "label": e.label, - "help": e.help, - "is_secret": e.is_secret, - "get_url": e.get_url, - })).collect::>(), - "has_oauth": t.oauth.is_some(), - "setup_instructions": t.setup_instructions, - }) - }) - .collect(); - - Json(serde_json::json!({ - "integrations": templates, - "count": templates.len(), - })) -} - -/// POST /api/integrations/add — Install an integration. -pub async fn add_integration( - State(state): State>, - Json(req): Json, -) -> impl IntoResponse { - let id = match req.get("id").and_then(|v| v.as_str()) { - Some(id) => id.to_string(), - None => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Missing 'id' field"})), - ); - } - }; - - // Scope the write lock so it's dropped before any .await - let install_err = { - let mut registry = state - .kernel - .extension_registry - .write() - .unwrap_or_else(|e| e.into_inner()); - - if registry.is_installed(&id) { - Some(( - StatusCode::CONFLICT, - format!("Integration '{}' already installed", id), - )) - } else if registry.get_template(&id).is_none() { - Some(( - StatusCode::NOT_FOUND, - format!("Unknown integration: '{}'", id), - )) - } else { - let entry = openfang_extensions::InstalledIntegration { - id: id.clone(), - installed_at: chrono::Utc::now(), - enabled: true, - oauth_provider: None, - config: std::collections::HashMap::new(), - }; - match registry.install(entry) { - Ok(_) => None, - Err(e) => Some((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), - } - } - }; // write lock dropped here - - if let Some((status, error)) = install_err { - return (status, Json(serde_json::json!({"error": error}))); - } - - state.kernel.extension_health.register(&id); - - // Hot-connect the new MCP server - let connected = state.kernel.reload_extension_mcps().await.unwrap_or(0); - - ( - StatusCode::CREATED, - Json(serde_json::json!({ - "id": id, - "status": "installed", - "connected": connected > 0, - "message": format!("Integration '{}' installed", id), - })), - ) -} - -/// DELETE /api/integrations/:id — Remove an integration. -pub async fn remove_integration( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - // Scope the write lock - let uninstall_err = { - let mut registry = state - .kernel - .extension_registry - .write() - .unwrap_or_else(|e| e.into_inner()); - registry.uninstall(&id).err() - }; - - if let Some(e) = uninstall_err { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": e.to_string()})), - ); - } - - state.kernel.extension_health.unregister(&id); - - // Hot-disconnect the removed MCP server - let _ = state.kernel.reload_extension_mcps().await; - - ( - StatusCode::OK, - Json(serde_json::json!({ - "id": id, - "status": "removed", - })), - ) -} - -/// POST /api/integrations/:id/reconnect — Reconnect an MCP server. -pub async fn reconnect_integration( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let is_installed = { - let registry = state - .kernel - .extension_registry - .read() - .unwrap_or_else(|e| e.into_inner()); - registry.is_installed(&id) - }; - - if !is_installed { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("Integration '{}' not installed", id)})), - ); - } - - match state.kernel.reconnect_extension_mcp(&id).await { - Ok(tool_count) => ( - StatusCode::OK, - Json(serde_json::json!({ - "id": id, - "status": "connected", - "tool_count": tool_count, - })), - ), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "id": id, - "status": "error", - "error": e, - })), - ), - } -} - -/// GET /api/integrations/health — Health status for all integrations. -pub async fn integrations_health(State(state): State>) -> impl IntoResponse { - let health_entries = state.kernel.extension_health.all_health(); - let entries: Vec = health_entries - .iter() - .map(|h| { - serde_json::json!({ - "id": h.id, - "status": h.status.to_string(), - "tool_count": h.tool_count, - "last_ok": h.last_ok.map(|t| t.to_rfc3339()), - "last_error": h.last_error, - "consecutive_failures": h.consecutive_failures, - "reconnecting": h.reconnecting, - "reconnect_attempts": h.reconnect_attempts, - "connected_since": h.connected_since.map(|t| t.to_rfc3339()), - }) - }) - .collect(); - - Json(serde_json::json!({ - "health": entries, - "count": entries.len(), - })) -} - -/// POST /api/integrations/reload — Hot-reload integration configs and reconnect MCP. -pub async fn reload_integrations(State(state): State>) -> impl IntoResponse { - match state.kernel.reload_extension_mcps().await { - Ok(connected) => ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "reloaded", - "new_connections": connected, - })), - ), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": e})), - ), - } -} - -// --------------------------------------------------------------------------- -// Scheduled Jobs (cron) endpoints -// --------------------------------------------------------------------------- - -/// The well-known shared-memory agent ID used for cross-agent KV storage. -/// Must match the value in `openfang-kernel/src/kernel.rs::shared_memory_agent_id()`. -fn schedule_shared_agent_id() -> AgentId { - AgentId(uuid::Uuid::from_bytes([ - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x01, - ])) -} - -const SCHEDULES_KEY: &str = "__openfang_schedules"; - -/// GET /api/schedules — List all cron-based scheduled jobs. -pub async fn list_schedules(State(state): State>) -> impl IntoResponse { - let agent_id = schedule_shared_agent_id(); - match state.kernel.memory.structured_get(agent_id, SCHEDULES_KEY) { - Ok(Some(serde_json::Value::Array(arr))) => { - let total = arr.len(); - Json(serde_json::json!({"schedules": arr, "total": total})) - } - Ok(_) => Json(serde_json::json!({"schedules": [], "total": 0})), - Err(e) => { - tracing::warn!("Failed to load schedules: {e}"); - Json(serde_json::json!({"schedules": [], "total": 0, "error": format!("{e}")})) - } - } -} - -/// POST /api/schedules — Create a new cron-based scheduled job. -pub async fn create_schedule( - State(state): State>, - Json(req): Json, -) -> impl IntoResponse { - let name = match req["name"].as_str() { - Some(n) if !n.is_empty() => n.to_string(), - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Missing 'name' field"})), - ); - } - }; - - let cron = match req["cron"].as_str() { - Some(c) if !c.is_empty() => c.to_string(), - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Missing 'cron' field"})), - ); - } - }; - - // Validate cron expression: must be 5 space-separated fields - let cron_parts: Vec<&str> = cron.split_whitespace().collect(); - if cron_parts.len() != 5 { - return ( - StatusCode::BAD_REQUEST, - Json( - serde_json::json!({"error": "Invalid cron expression: must have 5 fields (min hour dom mon dow)"}), - ), - ); - } - - let agent_id_str = req["agent_id"].as_str().unwrap_or("").to_string(); - if agent_id_str.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Missing required field: agent_id"})), - ); - } - // Validate agent exists (UUID or name lookup) - let agent_exists = if let Ok(aid) = agent_id_str.parse::() { - state.kernel.registry.get(aid).is_some() - } else { - state - .kernel - .registry - .list() - .iter() - .any(|a| a.name == agent_id_str) - }; - if !agent_exists { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("Agent not found: {agent_id_str}")})), - ); - } - let message = req["message"].as_str().unwrap_or("").to_string(); - let enabled = req.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true); - - let schedule_id = uuid::Uuid::new_v4().to_string(); - let entry = serde_json::json!({ - "id": schedule_id, - "name": name, - "cron": cron, - "agent_id": agent_id_str, - "message": message, - "enabled": enabled, - "created_at": chrono::Utc::now().to_rfc3339(), - "last_run": null, - "run_count": 0, - }); - - let shared_id = schedule_shared_agent_id(); - let mut schedules: Vec = - match state.kernel.memory.structured_get(shared_id, SCHEDULES_KEY) { - Ok(Some(serde_json::Value::Array(arr))) => arr, - _ => Vec::new(), - }; - - schedules.push(entry.clone()); - if let Err(e) = state.kernel.memory.structured_set( - shared_id, - SCHEDULES_KEY, - serde_json::Value::Array(schedules), - ) { - tracing::warn!("Failed to save schedule: {e}"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Failed to save schedule: {e}")})), - ); - } - - (StatusCode::CREATED, Json(entry)) -} - -/// PUT /api/schedules/:id — Update a scheduled job (toggle enabled, edit fields). -pub async fn update_schedule( - State(state): State>, - Path(id): Path, - Json(req): Json, -) -> impl IntoResponse { - let shared_id = schedule_shared_agent_id(); - let mut schedules: Vec = - match state.kernel.memory.structured_get(shared_id, SCHEDULES_KEY) { - Ok(Some(serde_json::Value::Array(arr))) => arr, - _ => Vec::new(), - }; - - let mut found = false; - for s in schedules.iter_mut() { - if s["id"].as_str() == Some(&id) { - found = true; - if let Some(enabled) = req.get("enabled").and_then(|v| v.as_bool()) { - s["enabled"] = serde_json::Value::Bool(enabled); - } - if let Some(name) = req.get("name").and_then(|v| v.as_str()) { - s["name"] = serde_json::Value::String(name.to_string()); - } - if let Some(cron) = req.get("cron").and_then(|v| v.as_str()) { - let cron_parts: Vec<&str> = cron.split_whitespace().collect(); - if cron_parts.len() != 5 { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid cron expression"})), - ); - } - s["cron"] = serde_json::Value::String(cron.to_string()); - } - if let Some(agent_id) = req.get("agent_id").and_then(|v| v.as_str()) { - s["agent_id"] = serde_json::Value::String(agent_id.to_string()); - } - if let Some(message) = req.get("message").and_then(|v| v.as_str()) { - s["message"] = serde_json::Value::String(message.to_string()); - } - break; - } - } - - if !found { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Schedule not found"})), - ); - } - - if let Err(e) = state.kernel.memory.structured_set( - shared_id, - SCHEDULES_KEY, - serde_json::Value::Array(schedules), - ) { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Failed to update schedule: {e}")})), - ); - } - - ( - StatusCode::OK, - Json(serde_json::json!({"status": "updated", "schedule_id": id})), - ) -} - -/// DELETE /api/schedules/:id — Remove a scheduled job. -pub async fn delete_schedule( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let shared_id = schedule_shared_agent_id(); - let mut schedules: Vec = - match state.kernel.memory.structured_get(shared_id, SCHEDULES_KEY) { - Ok(Some(serde_json::Value::Array(arr))) => arr, - _ => Vec::new(), - }; - - let before = schedules.len(); - schedules.retain(|s| s["id"].as_str() != Some(&id)); - - if schedules.len() == before { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Schedule not found"})), - ); - } - - if let Err(e) = state.kernel.memory.structured_set( - shared_id, - SCHEDULES_KEY, - serde_json::Value::Array(schedules), - ) { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Failed to delete schedule: {e}")})), - ); - } - - ( - StatusCode::OK, - Json(serde_json::json!({"status": "removed", "schedule_id": id})), - ) -} - -/// POST /api/schedules/:id/run — Manually run a scheduled job now. -pub async fn run_schedule( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let shared_id = schedule_shared_agent_id(); - let schedules: Vec = - match state.kernel.memory.structured_get(shared_id, SCHEDULES_KEY) { - Ok(Some(serde_json::Value::Array(arr))) => arr, - _ => Vec::new(), - }; - - let schedule = match schedules.iter().find(|s| s["id"].as_str() == Some(&id)) { - Some(s) => s.clone(), - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Schedule not found"})), - ); - } - }; - - let agent_id_str = schedule["agent_id"].as_str().unwrap_or(""); - let message = schedule["message"] - .as_str() - .unwrap_or("Scheduled task triggered manually."); - let name = schedule["name"].as_str().unwrap_or("(unnamed)"); - - // Find the target agent — require explicit agent_id, no silent fallback - let target_agent = if !agent_id_str.is_empty() { - if let Ok(aid) = agent_id_str.parse::() { - if state.kernel.registry.get(aid).is_some() { - Some(aid) - } else { - None - } - } else { - state - .kernel - .registry - .list() - .iter() - .find(|a| a.name == agent_id_str) - .map(|a| a.id) - } - } else { - None - }; - - let target_agent = match target_agent { - Some(a) => a, - None => { - return ( - StatusCode::NOT_FOUND, - Json( - serde_json::json!({"error": "No target agent found. Specify an agent_id or start an agent first."}), - ), - ); - } - }; - - let run_message = if message.is_empty() { - format!("[Scheduled task '{}' triggered manually]", name) - } else { - message.to_string() - }; - - // Update last_run and run_count - let mut schedules_updated: Vec = - match state.kernel.memory.structured_get(shared_id, SCHEDULES_KEY) { - Ok(Some(serde_json::Value::Array(arr))) => arr, - _ => Vec::new(), - }; - for s in schedules_updated.iter_mut() { - if s["id"].as_str() == Some(&id) { - s["last_run"] = serde_json::Value::String(chrono::Utc::now().to_rfc3339()); - let count = s["run_count"].as_u64().unwrap_or(0); - s["run_count"] = serde_json::json!(count + 1); - break; - } - } - let _ = state.kernel.memory.structured_set( - shared_id, - SCHEDULES_KEY, - serde_json::Value::Array(schedules_updated), - ); - - let kernel_handle: Arc = state.kernel.clone() as Arc; - match state.kernel.send_message_with_handle(target_agent, &run_message, Some(kernel_handle)).await { - Ok(result) => ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "completed", - "schedule_id": id, - "agent_id": target_agent.to_string(), - "response": result.response, - })), - ), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "status": "failed", - "schedule_id": id, - "error": format!("{e}"), - })), - ), - } -} - -// --------------------------------------------------------------------------- -// Agent Identity endpoint -// --------------------------------------------------------------------------- - -/// Request body for updating agent visual identity. -#[derive(serde::Deserialize)] -pub struct UpdateIdentityRequest { - pub emoji: Option, - pub avatar_url: Option, - pub color: Option, - #[serde(default)] - pub archetype: Option, - #[serde(default)] - pub vibe: Option, - #[serde(default)] - pub greeting_style: Option, -} - -/// PATCH /api/agents/{id}/identity — Update an agent's visual identity. -pub async fn update_agent_identity( - State(state): State>, - Path(id): Path, - Json(req): Json, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ); - } - }; - - // Validate color format if provided - if let Some(ref color) = req.color { - if !color.is_empty() && !color.starts_with('#') { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Color must be a hex code starting with '#'"})), - ); - } - } - - // Validate avatar_url if provided - if let Some(ref url) = req.avatar_url { - if !url.is_empty() - && !url.starts_with("http://") - && !url.starts_with("https://") - && !url.starts_with("data:") - { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Avatar URL must be http/https or data URI"})), - ); - } - } - - let identity = AgentIdentity { - emoji: req.emoji, - avatar_url: req.avatar_url, - color: req.color, - archetype: req.archetype, - vibe: req.vibe, - greeting_style: req.greeting_style, - }; - - match state.kernel.registry.update_identity(agent_id, identity) { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "ok", "agent_id": id})), - ), - Err(_) => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ), - } -} - -// --------------------------------------------------------------------------- -// Agent Config Hot-Update -// --------------------------------------------------------------------------- - -/// Request body for patching agent config (name, description, prompt, identity, model). -#[derive(serde::Deserialize)] -pub struct PatchAgentConfigRequest { - pub name: Option, - pub description: Option, - pub system_prompt: Option, - pub emoji: Option, - pub avatar_url: Option, - pub color: Option, - pub archetype: Option, - pub vibe: Option, - pub greeting_style: Option, - pub model: Option, - pub provider: Option, - pub api_key_env: Option, - pub base_url: Option, -} - -/// PATCH /api/agents/{id}/config — Hot-update agent name, description, system prompt, and identity. -pub async fn patch_agent_config( - State(state): State>, - Path(id): Path, - Json(req): Json, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ); - } - }; - - // Input length limits - const MAX_NAME_LEN: usize = 256; - const MAX_DESC_LEN: usize = 4096; - const MAX_PROMPT_LEN: usize = 65_536; - - if let Some(ref name) = req.name { - if name.len() > MAX_NAME_LEN { - return ( - StatusCode::PAYLOAD_TOO_LARGE, - Json(serde_json::json!({"error": format!("Name exceeds max length ({MAX_NAME_LEN} chars)")})), - ); - } - } - if let Some(ref desc) = req.description { - if desc.len() > MAX_DESC_LEN { - return ( - StatusCode::PAYLOAD_TOO_LARGE, - Json(serde_json::json!({"error": format!("Description exceeds max length ({MAX_DESC_LEN} chars)")})), - ); - } - } - if let Some(ref prompt) = req.system_prompt { - if prompt.len() > MAX_PROMPT_LEN { - return ( - StatusCode::PAYLOAD_TOO_LARGE, - Json(serde_json::json!({"error": format!("System prompt exceeds max length ({MAX_PROMPT_LEN} chars)")})), - ); - } - } - - // Validate color format if provided - if let Some(ref color) = req.color { - if !color.is_empty() && !color.starts_with('#') { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Color must be a hex code starting with '#'"})), - ); - } - } - - // Validate avatar_url if provided - if let Some(ref url) = req.avatar_url { - if !url.is_empty() - && !url.starts_with("http://") - && !url.starts_with("https://") - && !url.starts_with("data:") - { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Avatar URL must be http/https or data URI"})), - ); - } - } - - // Update name - if let Some(ref new_name) = req.name { - if !new_name.is_empty() { - if let Err(e) = state - .kernel - .registry - .update_name(agent_id, new_name.clone()) - { - return ( - StatusCode::CONFLICT, - Json(serde_json::json!({"error": format!("{e}")})), - ); - } - } - } - - // Update description - if let Some(ref new_desc) = req.description { - if state - .kernel - .registry - .update_description(agent_id, new_desc.clone()) - .is_err() - { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ); - } - } - - // Update system prompt (hot-swap — takes effect on next message) - if let Some(ref new_prompt) = req.system_prompt { - if state - .kernel - .registry - .update_system_prompt(agent_id, new_prompt.clone()) - .is_err() - { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ); - } - } - - // Update identity fields (merge — only overwrite provided fields) - let has_identity_field = req.emoji.is_some() - || req.avatar_url.is_some() - || req.color.is_some() - || req.archetype.is_some() - || req.vibe.is_some() - || req.greeting_style.is_some(); - - if has_identity_field { - // Read current identity, merge with provided fields - let current = state - .kernel - .registry - .get(agent_id) - .map(|e| e.identity) - .unwrap_or_default(); - let merged = AgentIdentity { - emoji: req.emoji.or(current.emoji), - avatar_url: req.avatar_url.or(current.avatar_url), - color: req.color.or(current.color), - archetype: req.archetype.or(current.archetype), - vibe: req.vibe.or(current.vibe), - greeting_style: req.greeting_style.or(current.greeting_style), - }; - if state - .kernel - .registry - .update_identity(agent_id, merged) - .is_err() - { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ); - } - } - - // Update model/provider - if let Some(ref new_model) = req.model { - if !new_model.is_empty() { - if let Some(ref new_provider) = req.provider { - if !new_provider.is_empty() { - if state - .kernel - .registry - .update_model_and_provider( - agent_id, - new_model.clone(), - new_provider.clone(), - ) - .is_err() - { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ); - } - } else if state - .kernel - .registry - .update_model(agent_id, new_model.clone()) - .is_err() - { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ); - } - } else if state - .kernel - .registry - .update_model(agent_id, new_model.clone()) - .is_err() - { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ); - } - } - } - - // Persist updated manifest to database so changes survive restart - if let Some(entry) = state.kernel.registry.get(agent_id) { - if let Err(e) = state.kernel.memory.save_agent(&entry) { - tracing::warn!("Failed to persist agent config update: {e}"); - } - } - - ( - StatusCode::OK, - Json(serde_json::json!({"status": "ok", "agent_id": id})), - ) -} - -// --------------------------------------------------------------------------- -// Agent Cloning -// --------------------------------------------------------------------------- - -/// Request body for cloning an agent. -#[derive(serde::Deserialize)] -pub struct CloneAgentRequest { - pub new_name: String, -} - -/// POST /api/agents/{id}/clone — Clone an agent with its workspace files. -pub async fn clone_agent( - State(state): State>, - Path(id): Path, - Json(req): Json, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ); - } - }; - - if req.new_name.len() > 256 { - return ( - StatusCode::PAYLOAD_TOO_LARGE, - Json(serde_json::json!({"error": "Name exceeds max length (256 chars)"})), - ); - } - - if req.new_name.trim().is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "new_name cannot be empty"})), - ); - } - - let source = match state.kernel.registry.get(agent_id) { - Some(e) => e, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ); - } - }; - - // Deep-clone manifest with new name - let mut cloned_manifest = source.manifest.clone(); - cloned_manifest.name = req.new_name.clone(); - cloned_manifest.workspace = None; // Let kernel assign a new workspace - - // Spawn the cloned agent - let new_id = match state.kernel.spawn_agent(cloned_manifest) { - Ok(id) => id, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Clone spawn failed: {e}")})), - ); - } - }; - - // Copy workspace files from source to destination - let new_entry = state.kernel.registry.get(new_id); - if let (Some(ref src_ws), Some(ref new_entry)) = (source.manifest.workspace, new_entry) { - if let Some(ref dst_ws) = new_entry.manifest.workspace { - // Security: canonicalize both paths - if let (Ok(src_can), Ok(dst_can)) = (src_ws.canonicalize(), dst_ws.canonicalize()) { - for &fname in KNOWN_IDENTITY_FILES { - let src_file = src_can.join(fname); - let dst_file = dst_can.join(fname); - if src_file.exists() { - let _ = std::fs::copy(&src_file, &dst_file); - } - } - } - } - } - - // Copy identity from source - let _ = state - .kernel - .registry - .update_identity(new_id, source.identity.clone()); - - ( - StatusCode::CREATED, - Json(serde_json::json!({ - "agent_id": new_id.to_string(), - "name": req.new_name, - })), - ) -} - -// --------------------------------------------------------------------------- -// Workspace File Editor endpoints -// --------------------------------------------------------------------------- - -/// Whitelisted workspace identity files that can be read/written via API. -const KNOWN_IDENTITY_FILES: &[&str] = &[ - "SOUL.md", - "IDENTITY.md", - "USER.md", - "TOOLS.md", - "MEMORY.md", - "AGENTS.md", - "BOOTSTRAP.md", - "HEARTBEAT.md", -]; - -/// GET /api/agents/{id}/files — List workspace identity files. -pub async fn list_agent_files( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ); - } - }; - - let entry = match state.kernel.registry.get(agent_id) { - Some(e) => e, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ); - } - }; - - let workspace = match entry.manifest.workspace { - Some(ref ws) => ws.clone(), - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent has no workspace"})), - ); - } - }; - - let mut files = Vec::new(); - for &name in KNOWN_IDENTITY_FILES { - let path = workspace.join(name); - let (exists, size_bytes) = if path.exists() { - let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); - (true, size) - } else { - (false, 0u64) - }; - files.push(serde_json::json!({ - "name": name, - "exists": exists, - "size_bytes": size_bytes, - })); - } - - (StatusCode::OK, Json(serde_json::json!({ "files": files }))) -} - -/// GET /api/agents/{id}/files/{filename} — Read a workspace identity file. -pub async fn get_agent_file( - State(state): State>, - Path((id, filename)): Path<(String, String)>, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ); - } - }; - - // Validate filename whitelist - if !KNOWN_IDENTITY_FILES.contains(&filename.as_str()) { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "File not in whitelist"})), - ); - } - - let entry = match state.kernel.registry.get(agent_id) { - Some(e) => e, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ); - } - }; - - let workspace = match entry.manifest.workspace { - Some(ref ws) => ws.clone(), - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent has no workspace"})), - ); - } - }; - - // Security: canonicalize and verify stays inside workspace - let file_path = workspace.join(&filename); - let canonical = match file_path.canonicalize() { - Ok(p) => p, - Err(_) => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "File not found"})), - ); - } - }; - let ws_canonical = match workspace.canonicalize() { - Ok(p) => p, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "Workspace path error"})), - ); - } - }; - if !canonical.starts_with(&ws_canonical) { - return ( - StatusCode::FORBIDDEN, - Json(serde_json::json!({"error": "Path traversal denied"})), - ); - } - - let content = match std::fs::read_to_string(&canonical) { - Ok(c) => c, - Err(_) => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "File not found"})), - ); - } - }; - - let size_bytes = content.len(); - ( - StatusCode::OK, - Json(serde_json::json!({ - "name": filename, - "content": content, - "size_bytes": size_bytes, - })), - ) -} - -/// Request body for writing a workspace identity file. -#[derive(serde::Deserialize)] -pub struct SetAgentFileRequest { - pub content: String, -} - -/// PUT /api/agents/{id}/files/{filename} — Write a workspace identity file. -pub async fn set_agent_file( - State(state): State>, - Path((id, filename)): Path<(String, String)>, - Json(req): Json, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ); - } - }; - - // Validate filename whitelist - if !KNOWN_IDENTITY_FILES.contains(&filename.as_str()) { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "File not in whitelist"})), - ); - } - - // Max 32KB content - const MAX_FILE_SIZE: usize = 32_768; - if req.content.len() > MAX_FILE_SIZE { - return ( - StatusCode::PAYLOAD_TOO_LARGE, - Json(serde_json::json!({"error": "File content too large (max 32KB)"})), - ); - } - - let entry = match state.kernel.registry.get(agent_id) { - Some(e) => e, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ); - } - }; - - let workspace = match entry.manifest.workspace { - Some(ref ws) => ws.clone(), - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent has no workspace"})), - ); - } - }; - - // Security: verify workspace path and target stays inside it - let ws_canonical = match workspace.canonicalize() { - Ok(p) => p, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "Workspace path error"})), - ); - } - }; - - let file_path = workspace.join(&filename); - // For new files, check the parent directory instead - let check_path = if file_path.exists() { - file_path - .canonicalize() - .unwrap_or_else(|_| file_path.clone()) - } else { - // Parent must be inside workspace - file_path - .parent() - .and_then(|p| p.canonicalize().ok()) - .map(|p| p.join(&filename)) - .unwrap_or_else(|| file_path.clone()) - }; - if !check_path.starts_with(&ws_canonical) { - return ( - StatusCode::FORBIDDEN, - Json(serde_json::json!({"error": "Path traversal denied"})), - ); - } - - // Atomic write: write to .tmp, then rename - let tmp_path = workspace.join(format!(".{filename}.tmp")); - if let Err(e) = std::fs::write(&tmp_path, &req.content) { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Write failed: {e}")})), - ); - } - if let Err(e) = std::fs::rename(&tmp_path, &file_path) { - let _ = std::fs::remove_file(&tmp_path); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Rename failed: {e}")})), - ); - } - - let size_bytes = req.content.len(); - ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "ok", - "name": filename, - "size_bytes": size_bytes, - })), - ) -} - -// --------------------------------------------------------------------------- -// File Upload endpoints -// --------------------------------------------------------------------------- - -/// Response body for file uploads. -#[derive(serde::Serialize)] -struct UploadResponse { - file_id: String, - filename: String, - content_type: String, - size: usize, - /// Transcription text for audio uploads (populated via Whisper STT). - #[serde(skip_serializing_if = "Option::is_none")] - transcription: Option, -} - -/// Metadata stored alongside uploaded files. -struct UploadMeta { - #[allow(dead_code)] - filename: String, - content_type: String, -} - -/// In-memory upload metadata registry. -static UPLOAD_REGISTRY: LazyLock> = LazyLock::new(DashMap::new); - -/// Maximum upload size: 10 MB. -const MAX_UPLOAD_SIZE: usize = 10 * 1024 * 1024; - -/// Allowed content type prefixes for upload. -const ALLOWED_CONTENT_TYPES: &[&str] = &["image/", "text/", "application/pdf", "audio/"]; - -fn is_allowed_content_type(ct: &str) -> bool { - ALLOWED_CONTENT_TYPES - .iter() - .any(|prefix| ct.starts_with(prefix)) -} - -/// POST /api/agents/{id}/upload — Upload a file attachment. -/// -/// Accepts raw body bytes. The client must set: -/// - `Content-Type` header (e.g., `image/png`, `text/plain`, `application/pdf`) -/// - `X-Filename` header (original filename) -pub async fn upload_file( - State(state): State>, - Path(id): Path, - headers: axum::http::HeaderMap, - body: axum::body::Bytes, -) -> impl IntoResponse { - // Validate agent ID format - let _agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent ID"})), - ); - } - }; - - // Extract content type - let content_type = headers - .get(axum::http::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("application/octet-stream") - .to_string(); - - if !is_allowed_content_type(&content_type) { - return ( - StatusCode::BAD_REQUEST, - Json( - serde_json::json!({"error": "Unsupported content type. Allowed: image/*, text/*, audio/*, application/pdf"}), - ), - ); - } - - // Extract filename from header - let filename = headers - .get("X-Filename") - .and_then(|v| v.to_str().ok()) - .unwrap_or("upload") - .to_string(); - - // Validate size - if body.len() > MAX_UPLOAD_SIZE { - return ( - StatusCode::PAYLOAD_TOO_LARGE, - Json( - serde_json::json!({"error": format!("File too large (max {} MB)", MAX_UPLOAD_SIZE / (1024 * 1024))}), - ), - ); - } - - if body.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Empty file body"})), - ); - } - - // Generate file ID and save - let file_id = uuid::Uuid::new_v4().to_string(); - let upload_dir = std::env::temp_dir().join("openfang_uploads"); - if let Err(e) = std::fs::create_dir_all(&upload_dir) { - tracing::warn!("Failed to create upload dir: {e}"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "Failed to create upload directory"})), - ); - } - - let file_path = upload_dir.join(&file_id); - if let Err(e) = std::fs::write(&file_path, &body) { - tracing::warn!("Failed to write upload: {e}"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "Failed to save file"})), - ); - } - - let size = body.len(); - UPLOAD_REGISTRY.insert( - file_id.clone(), - UploadMeta { - filename: filename.clone(), - content_type: content_type.clone(), - }, - ); - - // Auto-transcribe audio uploads using the media engine - let transcription = if content_type.starts_with("audio/") { - let attachment = openfang_types::media::MediaAttachment { - media_type: openfang_types::media::MediaType::Audio, - mime_type: content_type.clone(), - source: openfang_types::media::MediaSource::FilePath { - path: file_path.to_string_lossy().to_string(), - }, - size_bytes: size as u64, - }; - match state - .kernel - .media_engine - .transcribe_audio(&attachment) - .await - { - Ok(result) => { - tracing::info!(chars = result.description.len(), provider = %result.provider, "Audio transcribed"); - Some(result.description) - } - Err(e) => { - tracing::warn!("Audio transcription failed: {e}"); - None - } - } - } else { - None - }; - - ( - StatusCode::CREATED, - Json(serde_json::json!(UploadResponse { - file_id, - filename, - content_type, - size, - transcription, - })), - ) -} - -/// GET /api/uploads/{file_id} — Serve an uploaded file. -pub async fn serve_upload(Path(file_id): Path) -> impl IntoResponse { - // Validate file_id is a UUID to prevent path traversal - if uuid::Uuid::parse_str(&file_id).is_err() { - return ( - StatusCode::BAD_REQUEST, - [( - axum::http::header::CONTENT_TYPE, - "application/json".to_string(), - )], - b"{\"error\":\"Invalid file ID\"}".to_vec(), - ); - } - - let file_path = std::env::temp_dir().join("openfang_uploads").join(&file_id); - - // Look up metadata from registry; fall back to disk probe for generated images - // (image_generate saves files without registering in UPLOAD_REGISTRY). - let content_type = match UPLOAD_REGISTRY.get(&file_id) { - Some(m) => m.content_type.clone(), - None => { - // Infer content type from file magic bytes - if !file_path.exists() { - return ( - StatusCode::NOT_FOUND, - [( - axum::http::header::CONTENT_TYPE, - "application/json".to_string(), - )], - b"{\"error\":\"File not found\"}".to_vec(), - ); - } - "image/png".to_string() - } - }; - - match std::fs::read(&file_path) { - Ok(data) => ( - StatusCode::OK, - [(axum::http::header::CONTENT_TYPE, content_type)], - data, - ), - Err(_) => ( - StatusCode::NOT_FOUND, - [( - axum::http::header::CONTENT_TYPE, - "application/json".to_string(), - )], - b"{\"error\":\"File not found on disk\"}".to_vec(), - ), - } -} - -// --------------------------------------------------------------------------- -// Execution Approval System — backed by kernel.approval_manager -// --------------------------------------------------------------------------- - -/// GET /api/approvals — List pending approval requests. -/// -/// Transforms field names to match the dashboard template expectations: -/// `action_summary` → `action`, `agent_id` → `agent_name`, `requested_at` → `created_at`. -pub async fn list_approvals(State(state): State>) -> impl IntoResponse { - let pending = state.kernel.approval_manager.list_pending(); - let total = pending.len(); - - // Resolve agent names for display - let registry_agents = state.kernel.registry.list(); - - let approvals: Vec = pending - .into_iter() - .map(|a| { - let agent_name = registry_agents - .iter() - .find(|ag| ag.id.to_string() == a.agent_id || ag.name == a.agent_id) - .map(|ag| ag.name.as_str()) - .unwrap_or(&a.agent_id); - serde_json::json!({ - "id": a.id, - "agent_id": a.agent_id, - "agent_name": agent_name, - "tool_name": a.tool_name, - "description": a.description, - "action_summary": a.action_summary, - "action": a.action_summary, - "risk_level": a.risk_level, - "requested_at": a.requested_at, - "created_at": a.requested_at, - "timeout_secs": a.timeout_secs, - "status": "pending" - }) - }) - .collect(); - - Json(serde_json::json!({"approvals": approvals, "total": total})) -} - -/// POST /api/approvals — Create a manual approval request (for external systems). -/// -/// Note: Most approval requests are created automatically by the tool_runner -/// when an agent invokes a tool that requires approval. This endpoint exists -/// for external integrations that need to inject approval gates. -#[derive(serde::Deserialize)] -pub struct CreateApprovalRequest { - pub agent_id: String, - pub tool_name: String, - #[serde(default)] - pub description: String, - #[serde(default)] - pub action_summary: String, -} - -pub async fn create_approval( - State(state): State>, - Json(req): Json, -) -> impl IntoResponse { - use openfang_types::approval::{ApprovalRequest, RiskLevel}; - - let policy = state.kernel.approval_manager.policy(); - let id = uuid::Uuid::new_v4(); - let approval_req = ApprovalRequest { - id, - agent_id: req.agent_id, - tool_name: req.tool_name.clone(), - description: if req.description.is_empty() { - format!("Manual approval request for {}", req.tool_name) - } else { - req.description - }, - action_summary: if req.action_summary.is_empty() { - req.tool_name.clone() - } else { - req.action_summary - }, - risk_level: RiskLevel::High, - requested_at: chrono::Utc::now(), - timeout_secs: policy.timeout_secs, - }; - - // Spawn the request in the background (it will block until resolved or timed out) - let kernel = Arc::clone(&state.kernel); - tokio::spawn(async move { - kernel.approval_manager.request_approval(approval_req).await; - }); - - ( - StatusCode::CREATED, - Json(serde_json::json!({"id": id.to_string(), "status": "pending"})), - ) -} - -/// POST /api/approvals/{id}/approve — Approve a pending request. -pub async fn approve_request( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let uuid = match uuid::Uuid::parse_str(&id) { - Ok(u) => u, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid approval ID"})), - ); - } - }; - - match state.kernel.approval_manager.resolve( - uuid, - openfang_types::approval::ApprovalDecision::Approved, - Some("api".to_string()), - ) { - Ok(resp) => ( - StatusCode::OK, - Json( - serde_json::json!({"id": id, "status": "approved", "decided_at": resp.decided_at.to_rfc3339()}), - ), - ), - Err(e) => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": e}))), - } -} - -/// POST /api/approvals/{id}/reject — Reject a pending request. -pub async fn reject_request( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let uuid = match uuid::Uuid::parse_str(&id) { - Ok(u) => u, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid approval ID"})), - ); - } - }; - - match state.kernel.approval_manager.resolve( - uuid, - openfang_types::approval::ApprovalDecision::Denied, - Some("api".to_string()), - ) { - Ok(resp) => ( - StatusCode::OK, - Json( - serde_json::json!({"id": id, "status": "rejected", "decided_at": resp.decided_at.to_rfc3339()}), - ), - ), - Err(e) => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": e}))), - } -} - -// --------------------------------------------------------------------------- -// Config Reload endpoint -// --------------------------------------------------------------------------- - -/// POST /api/config/reload — Reload configuration from disk and apply hot-reloadable changes. -/// -/// Reads the config file, diffs against current config, validates the new config, -/// and applies hot-reloadable actions (approval policy, cron limits, etc.). -/// Returns the reload plan showing what changed and what was applied. -pub async fn config_reload(State(state): State>) -> impl IntoResponse { - // SECURITY: Record config reload in audit trail - state.kernel.audit_log.record( - "system", - openfang_runtime::audit::AuditAction::ConfigChange, - "config reload requested via API", - "pending", - ); - match state.kernel.reload_config() { - Ok(plan) => { - let status = if plan.restart_required { - "partial" - } else if plan.has_changes() { - "applied" - } else { - "no_changes" - }; - - ( - StatusCode::OK, - Json(serde_json::json!({ - "status": status, - "restart_required": plan.restart_required, - "restart_reasons": plan.restart_reasons, - "hot_actions_applied": plan.hot_actions.iter().map(|a| format!("{a:?}")).collect::>(), - "noop_changes": plan.noop_changes, - })), - ) - } - Err(e) => ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"status": "error", "error": e})), - ), - } -} - -// --------------------------------------------------------------------------- -// Config Schema endpoint -// --------------------------------------------------------------------------- - -/// GET /api/config/schema — Return a simplified JSON description of the config structure. -pub async fn config_schema( - State(state): State>, -) -> impl IntoResponse { - // Build provider/model options from model catalog for dropdowns - let catalog = state.kernel.model_catalog.read().unwrap_or_else(|e| e.into_inner()); - let provider_options: Vec = catalog.list_providers().iter().map(|p| p.id.clone()).collect(); - let model_options: Vec = catalog - .list_models() - .iter() - .map(|m| serde_json::json!({"id": m.id, "name": m.display_name, "provider": m.provider})) - .collect(); - drop(catalog); - - Json(serde_json::json!({ - "sections": { - "api": { - "fields": { - "api_listen": "string", - "api_key": "string", - "log_level": "string" - } - }, - "default_model": { - "hot_reloadable": true, - "fields": { - "provider": { "type": "select", "options": provider_options }, - "model": { "type": "select", "options": model_options }, - "api_key_env": "string", - "base_url": "string" - } - }, - "memory": { - "fields": { - "decay_rate": "number", - "vector_dims": "number" - } - }, - "web": { - "fields": { - "provider": "string", - "timeout_secs": "number", - "max_results": "number" - } - }, - "browser": { - "fields": { - "headless": "boolean", - "timeout_secs": "number", - "executable_path": "string" - } - }, - "network": { - "fields": { - "enabled": "boolean", - "listen_addr": "string", - "shared_secret": "string" - } - }, - "extensions": { - "fields": { - "auto_connect": "boolean", - "health_check_interval_secs": "number" - } - }, - "vault": { - "fields": { - "path": "string" - } - }, - "a2a": { - "fields": { - "enabled": "boolean", - "name": "string", - "description": "string", - "url": "string" - } - }, - "channels": { - "fields": { - "telegram": "object", - "discord": "object", - "slack": "object", - "whatsapp": "object" - } - } - } - })) -} - -// --------------------------------------------------------------------------- -// Config Set endpoint -// --------------------------------------------------------------------------- - -/// POST /api/config/set — Set a single config value and persist to config.toml. -/// -/// Accepts JSON `{ "path": "section.key", "value": "..." }`. -/// Writes the value to the TOML config file and triggers a reload. -pub async fn config_set( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - let path = match body.get("path").and_then(|v| v.as_str()) { - Some(p) => p.to_string(), - None => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"status": "error", "error": "missing 'path' field"})), - ); - } - }; - let value = match body.get("value") { - Some(v) => v.clone(), - None => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"status": "error", "error": "missing 'value' field"})), - ); - } - }; - - let config_path = state.kernel.config.home_dir.join("config.toml"); - - // Read existing config as a TOML table, or start fresh - let mut table: toml::value::Table = if config_path.exists() { - match std::fs::read_to_string(&config_path) { - Ok(content) => toml::from_str(&content).unwrap_or_default(), - Err(_) => toml::value::Table::new(), - } - } else { - toml::value::Table::new() - }; - - // Convert JSON value to TOML value - let toml_val = json_to_toml_value(&value); - - // Parse "section.key" path and set value - let parts: Vec<&str> = path.split('.').collect(); - match parts.len() { - 1 => { - table.insert(parts[0].to_string(), toml_val); - } - 2 => { - let section = table - .entry(parts[0].to_string()) - .or_insert_with(|| toml::Value::Table(toml::value::Table::new())); - if let toml::Value::Table(ref mut t) = section { - t.insert(parts[1].to_string(), toml_val); - } - } - 3 => { - let section = table - .entry(parts[0].to_string()) - .or_insert_with(|| toml::Value::Table(toml::value::Table::new())); - if let toml::Value::Table(ref mut t) = section { - let sub = t - .entry(parts[1].to_string()) - .or_insert_with(|| toml::Value::Table(toml::value::Table::new())); - if let toml::Value::Table(ref mut t2) = sub { - t2.insert(parts[2].to_string(), toml_val); - } - } - } - _ => { - return ( - StatusCode::BAD_REQUEST, - Json( - serde_json::json!({"status": "error", "error": "path too deep (max 3 levels)"}), - ), - ); - } - } - - // Write back - let toml_string = match toml::to_string_pretty(&table) { - Ok(s) => s, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json( - serde_json::json!({"status": "error", "error": format!("serialize failed: {e}")}), - ), - ); - } - }; - if let Err(e) = std::fs::write(&config_path, &toml_string) { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"status": "error", "error": format!("write failed: {e}")})), - ); - } - - // Trigger reload - let reload_status = match state.kernel.reload_config() { - Ok(plan) => { - if plan.restart_required { - "applied_partial" - } else { - "applied" - } - } - Err(_) => "saved_reload_failed", - }; - - state.kernel.audit_log.record( - "system", - openfang_runtime::audit::AuditAction::ConfigChange, - format!("config set: {path}"), - "completed", - ); - - ( - StatusCode::OK, - Json(serde_json::json!({"status": reload_status, "path": path})), - ) -} - -/// Convert a serde_json::Value to a toml::Value. -fn json_to_toml_value(value: &serde_json::Value) -> toml::Value { - match value { - serde_json::Value::String(s) => toml::Value::String(s.clone()), - serde_json::Value::Number(n) => { - if let Some(i) = n.as_u64() { - toml::Value::Integer(i as i64) - } else if let Some(i) = n.as_i64() { - toml::Value::Integer(i) - } else if let Some(f) = n.as_f64() { - toml::Value::Float(f) - } else { - toml::Value::String(n.to_string()) - } - } - serde_json::Value::Bool(b) => toml::Value::Boolean(*b), - _ => toml::Value::String(value.to_string()), - } -} - -// --------------------------------------------------------------------------- -// Delivery tracking endpoints -// --------------------------------------------------------------------------- - -/// GET /api/agents/:id/deliveries — List recent delivery receipts for an agent. -pub async fn get_agent_deliveries( - State(state): State>, - Path(id): Path, - Query(params): Query>, -) -> impl IntoResponse { - let agent_id: AgentId = match id.parse() { - Ok(id) => id, - Err(_) => { - // Try name lookup - match state.kernel.registry.find_by_name(&id) { - Some(entry) => entry.id, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Agent not found"})), - ); - } - } - } - }; - - let limit = params - .get("limit") - .and_then(|v| v.parse::().ok()) - .unwrap_or(50) - .min(500); - - let receipts = state.kernel.delivery_tracker.get_receipts(agent_id, limit); - ( - StatusCode::OK, - Json(serde_json::json!({ - "agent_id": agent_id.to_string(), - "count": receipts.len(), - "receipts": receipts, - })), - ) -} - -// --------------------------------------------------------------------------- -// Cron job management endpoints -// --------------------------------------------------------------------------- - -/// GET /api/cron/jobs — List all cron jobs, optionally filtered by agent_id. -pub async fn list_cron_jobs( - State(state): State>, - Query(params): Query>, -) -> impl IntoResponse { - let jobs = if let Some(agent_id_str) = params.get("agent_id") { - match uuid::Uuid::parse_str(agent_id_str) { - Ok(uuid) => { - let aid = AgentId(uuid); - state.kernel.cron_scheduler.list_jobs(aid) - } - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid agent_id"})), - ); - } - } - } else { - state.kernel.cron_scheduler.list_all_jobs() - }; - let total = jobs.len(); - let jobs_json: Vec = jobs - .into_iter() - .map(|j| serde_json::to_value(&j).unwrap_or_default()) - .collect(); - ( - StatusCode::OK, - Json(serde_json::json!({"jobs": jobs_json, "total": total})), - ) -} - -/// POST /api/cron/jobs — Create a new cron job. -pub async fn create_cron_job( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - let agent_id = body["agent_id"].as_str().unwrap_or(""); - match state.kernel.cron_create(agent_id, body.clone()).await { - Ok(result) => ( - StatusCode::CREATED, - Json(serde_json::json!({"result": result})), - ), - Err(e) => ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": e})), - ), - } -} - -/// DELETE /api/cron/jobs/{id} — Delete a cron job. -pub async fn delete_cron_job( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - match uuid::Uuid::parse_str(&id) { - Ok(uuid) => { - let job_id = openfang_types::scheduler::CronJobId(uuid); - match state.kernel.cron_scheduler.remove_job(job_id) { - Ok(_) => { - let _ = state.kernel.cron_scheduler.persist(); - ( - StatusCode::OK, - Json(serde_json::json!({"status": "deleted"})), - ) - } - Err(e) => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("{e}")})), - ), - } - } - Err(_) => ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid job ID"})), - ), - } -} - -/// PUT /api/cron/jobs/{id}/enable — Enable or disable a cron job. -pub async fn toggle_cron_job( - State(state): State>, - Path(id): Path, - Json(body): Json, -) -> impl IntoResponse { - let enabled = body["enabled"].as_bool().unwrap_or(true); - match uuid::Uuid::parse_str(&id) { - Ok(uuid) => { - let job_id = openfang_types::scheduler::CronJobId(uuid); - match state.kernel.cron_scheduler.set_enabled(job_id, enabled) { - Ok(()) => { - let _ = state.kernel.cron_scheduler.persist(); - ( - StatusCode::OK, - Json(serde_json::json!({"id": id, "enabled": enabled})), - ) - } - Err(e) => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("{e}")})), - ), - } - } - Err(_) => ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid job ID"})), - ), - } -} - -/// GET /api/cron/jobs/{id}/status — Get status of a specific cron job. -pub async fn cron_job_status( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - match uuid::Uuid::parse_str(&id) { - Ok(uuid) => { - let job_id = openfang_types::scheduler::CronJobId(uuid); - match state.kernel.cron_scheduler.get_meta(job_id) { - Some(meta) => ( - StatusCode::OK, - Json(serde_json::to_value(&meta).unwrap_or_default()), - ), - None => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Job not found"})), - ), - } - } - Err(_) => ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid job ID"})), - ), - } -} - -// --------------------------------------------------------------------------- -// Webhook trigger endpoints -// --------------------------------------------------------------------------- - -/// POST /hooks/wake — Inject a system event via webhook trigger. -/// -/// Publishes a custom event through the kernel's event system, which can -/// trigger proactive agents that subscribe to the event type. -pub async fn webhook_wake( - State(state): State>, - headers: axum::http::HeaderMap, - Json(body): Json, -) -> impl IntoResponse { - // Check if webhook triggers are enabled - let wh_config = match &state.kernel.config.webhook_triggers { - Some(c) if c.enabled => c, - _ => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Webhook triggers not enabled"})), - ); - } - }; - - // Validate bearer token (constant-time comparison) - if !validate_webhook_token(&headers, &wh_config.token_env) { - return ( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({"error": "Invalid or missing token"})), - ); - } - - // Validate payload - if let Err(e) = body.validate() { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": e})), - ); - } - - // Publish through the kernel's publish_event (KernelHandle trait), which - // goes through the full event processing pipeline including trigger evaluation. - let event_payload = serde_json::json!({ - "source": "webhook", - "mode": body.mode, - "text": body.text, - }); - if let Err(e) = - KernelHandle::publish_event(state.kernel.as_ref(), "webhook.wake", event_payload).await - { - tracing::warn!("Webhook wake event publish failed: {e}"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Event publish failed: {e}")})), - ); - } - - ( - StatusCode::OK, - Json(serde_json::json!({"status": "accepted", "mode": body.mode})), - ) -} - -/// POST /hooks/agent — Run an isolated agent turn via webhook. -/// -/// Sends a message directly to the specified agent and returns the response. -/// This enables external systems (CI/CD, Slack, etc.) to trigger agent work. -pub async fn webhook_agent( - State(state): State>, - headers: axum::http::HeaderMap, - Json(body): Json, -) -> impl IntoResponse { - // Check if webhook triggers are enabled - let wh_config = match &state.kernel.config.webhook_triggers { - Some(c) if c.enabled => c, - _ => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Webhook triggers not enabled"})), - ); - } - }; - - // Validate bearer token - if !validate_webhook_token(&headers, &wh_config.token_env) { - return ( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({"error": "Invalid or missing token"})), - ); - } - - // Validate payload - if let Err(e) = body.validate() { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": e})), - ); - } - - // Resolve the agent by name or ID (if not specified, use the first running agent) - let agent_id: AgentId = match &body.agent { - Some(agent_ref) => match agent_ref.parse() { - Ok(id) => id, - Err(_) => { - // Try name lookup - match state.kernel.registry.find_by_name(agent_ref) { - Some(entry) => entry.id, - None => { - return ( - StatusCode::NOT_FOUND, - Json( - serde_json::json!({"error": format!("Agent not found: {}", agent_ref)}), - ), - ); - } - } - } - }, - None => { - // No agent specified — use the first available agent - match state.kernel.registry.list().first() { - Some(entry) => entry.id, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "No agents available"})), - ); - } - } - } - }; - - // Actually send the message to the agent and get the response - match state.kernel.send_message(agent_id, &body.message).await { - Ok(result) => ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "completed", - "agent_id": agent_id.to_string(), - "response": result.response, - "usage": { - "input_tokens": result.total_usage.input_tokens, - "output_tokens": result.total_usage.output_tokens, - }, - })), - ), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Agent execution failed: {e}")})), - ), - } -} - -// ─── Agent Bindings API ──────────────────────────────────────────────── - -/// GET /api/bindings — List all agent bindings. -pub async fn list_bindings(State(state): State>) -> impl IntoResponse { - let bindings = state.kernel.list_bindings(); - ( - StatusCode::OK, - Json(serde_json::json!({ "bindings": bindings })), - ) -} - -/// POST /api/bindings — Add a new agent binding. -pub async fn add_binding( - State(state): State>, - Json(binding): Json, -) -> impl IntoResponse { - // Validate agent exists - let agents = state.kernel.registry.list(); - let agent_exists = agents.iter().any(|e| e.name == binding.agent) - || binding.agent.parse::().is_ok(); - if !agent_exists { - tracing::warn!(agent = %binding.agent, "Binding references unknown agent"); - } - - state.kernel.add_binding(binding); - ( - StatusCode::CREATED, - Json(serde_json::json!({ "status": "created" })), - ) -} - -/// DELETE /api/bindings/:index — Remove a binding by index. -pub async fn remove_binding( - State(state): State>, - Path(index): Path, -) -> impl IntoResponse { - match state.kernel.remove_binding(index) { - Some(_) => ( - StatusCode::OK, - Json(serde_json::json!({ "status": "removed" })), - ), - None => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "Binding index out of range" })), - ), - } -} - -// ─── Device Pairing endpoints ─────────────────────────────────────────── - -/// POST /api/pairing/request — Create a new pairing request (returns token + QR URI). -pub async fn pairing_request(State(state): State>) -> impl IntoResponse { - if !state.kernel.config.pairing.enabled { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Pairing not enabled"})), - ) - .into_response(); - } - match state.kernel.pairing.create_pairing_request() { - Ok(req) => { - let qr_uri = format!("openfang://pair?token={}", req.token); - Json(serde_json::json!({ - "token": req.token, - "qr_uri": qr_uri, - "expires_at": req.expires_at.to_rfc3339(), - })) - .into_response() - } - Err(e) => ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": e})), - ) - .into_response(), - } -} - -/// POST /api/pairing/complete — Complete pairing with token + device info. -pub async fn pairing_complete( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - if !state.kernel.config.pairing.enabled { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Pairing not enabled"})), - ) - .into_response(); - } - let token = body.get("token").and_then(|v| v.as_str()).unwrap_or(""); - let display_name = body - .get("display_name") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let platform = body - .get("platform") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let push_token = body - .get("push_token") - .and_then(|v| v.as_str()) - .map(String::from); - let device_info = openfang_kernel::pairing::PairedDevice { - device_id: uuid::Uuid::new_v4().to_string(), - display_name: display_name.to_string(), - platform: platform.to_string(), - paired_at: chrono::Utc::now(), - last_seen: chrono::Utc::now(), - push_token, - }; - match state.kernel.pairing.complete_pairing(token, device_info) { - Ok(device) => Json(serde_json::json!({ - "device_id": device.device_id, - "display_name": device.display_name, - "platform": device.platform, - "paired_at": device.paired_at.to_rfc3339(), - })) - .into_response(), - Err(e) => ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": e})), - ) - .into_response(), - } -} - -/// GET /api/pairing/devices — List paired devices. -pub async fn pairing_devices(State(state): State>) -> impl IntoResponse { - if !state.kernel.config.pairing.enabled { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Pairing not enabled"})), - ) - .into_response(); - } - let devices: Vec<_> = state - .kernel - .pairing - .list_devices() - .into_iter() - .map(|d| { - serde_json::json!({ - "device_id": d.device_id, - "display_name": d.display_name, - "platform": d.platform, - "paired_at": d.paired_at.to_rfc3339(), - "last_seen": d.last_seen.to_rfc3339(), - }) - }) - .collect(); - Json(serde_json::json!({"devices": devices})).into_response() -} - -/// DELETE /api/pairing/devices/{id} — Remove a paired device. -pub async fn pairing_remove_device( - State(state): State>, - Path(device_id): Path, -) -> impl IntoResponse { - if !state.kernel.config.pairing.enabled { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Pairing not enabled"})), - ) - .into_response(); - } - match state.kernel.pairing.remove_device(&device_id) { - Ok(()) => Json(serde_json::json!({"ok": true})).into_response(), - Err(e) => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": e}))).into_response(), - } -} - -/// POST /api/pairing/notify — Push a notification to all paired devices. -pub async fn pairing_notify( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - if !state.kernel.config.pairing.enabled { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Pairing not enabled"})), - ) - .into_response(); - } - let title = body - .get("title") - .and_then(|v| v.as_str()) - .unwrap_or("OpenFang"); - let message = body.get("message").and_then(|v| v.as_str()).unwrap_or(""); - if message.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "message is required"})), - ) - .into_response(); - } - state.kernel.pairing.notify_devices(title, message).await; - Json(serde_json::json!({"ok": true, "notified": state.kernel.pairing.list_devices().len()})) - .into_response() -} - -/// GET /api/commands — List available chat commands (for dynamic slash menu). -pub async fn list_commands(State(state): State>) -> impl IntoResponse { - let mut commands = vec![ - serde_json::json!({"cmd": "/help", "desc": "Show available commands"}), - serde_json::json!({"cmd": "/new", "desc": "Reset session (clear history)"}), - serde_json::json!({"cmd": "/compact", "desc": "Trigger LLM session compaction"}), - serde_json::json!({"cmd": "/model", "desc": "Show or switch model (/model [name])"}), - serde_json::json!({"cmd": "/stop", "desc": "Cancel current agent run"}), - serde_json::json!({"cmd": "/usage", "desc": "Show session token usage & cost"}), - serde_json::json!({"cmd": "/think", "desc": "Toggle extended thinking (/think [on|off|stream])"}), - serde_json::json!({"cmd": "/context", "desc": "Show context window usage & pressure"}), - serde_json::json!({"cmd": "/verbose", "desc": "Cycle tool detail level (/verbose [off|on|full])"}), - serde_json::json!({"cmd": "/queue", "desc": "Check if agent is processing"}), - serde_json::json!({"cmd": "/status", "desc": "Show system status"}), - serde_json::json!({"cmd": "/clear", "desc": "Clear chat display"}), - serde_json::json!({"cmd": "/exit", "desc": "Disconnect from agent"}), - ]; - - // Add skill-registered tool names as potential commands - if let Ok(registry) = state.kernel.skill_registry.read() { - for skill in registry.list() { - let desc: String = skill.manifest.skill.description.chars().take(80).collect(); - commands.push(serde_json::json!({ - "cmd": format!("/{}", skill.manifest.skill.name), - "desc": if desc.is_empty() { format!("Skill: {}", skill.manifest.skill.name) } else { desc }, - "source": "skill", - })); - } - } - - Json(serde_json::json!({"commands": commands})) -} - -/// SECURITY: Validate webhook bearer token using constant-time comparison. -fn validate_webhook_token(headers: &axum::http::HeaderMap, token_env: &str) -> bool { - let expected = match std::env::var(token_env) { - Ok(t) if t.len() >= 32 => t, - _ => return false, - }; - - let provided = match headers.get("authorization") { - Some(v) => match v.to_str() { - Ok(s) if s.starts_with("Bearer ") => &s[7..], - _ => return false, - }, - None => return false, - }; - - use subtle::ConstantTimeEq; - if provided.len() != expected.len() { - return false; - } - provided.as_bytes().ct_eq(expected.as_bytes()).into() -} - -// ══════════════════════════════════════════════════════════════════════ -// GitHub Copilot OAuth Device Flow -// ══════════════════════════════════════════════════════════════════════ - -/// State for an in-progress device flow. -struct CopilotFlowState { - device_code: String, - interval: u64, - expires_at: Instant, -} - -/// Active device flows, keyed by poll_id. Auto-expire after the flow's TTL. -static COPILOT_FLOWS: LazyLock> = LazyLock::new(DashMap::new); - -/// POST /api/providers/github-copilot/oauth/start -/// -/// Initiates a GitHub device flow for Copilot authentication. -/// Returns a user code and verification URI that the user visits in their browser. -pub async fn copilot_oauth_start() -> impl IntoResponse { - // Clean up expired flows first - COPILOT_FLOWS.retain(|_, state| state.expires_at > Instant::now()); - - match openfang_runtime::copilot_oauth::start_device_flow().await { - Ok(resp) => { - let poll_id = uuid::Uuid::new_v4().to_string(); - - COPILOT_FLOWS.insert( - poll_id.clone(), - CopilotFlowState { - device_code: resp.device_code, - interval: resp.interval, - expires_at: Instant::now() - + std::time::Duration::from_secs(resp.expires_in), - }, - ); - - ( - StatusCode::OK, - Json(serde_json::json!({ - "user_code": resp.user_code, - "verification_uri": resp.verification_uri, - "poll_id": poll_id, - "expires_in": resp.expires_in, - "interval": resp.interval, - })), - ) - } - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": e })), - ), - } -} - -/// GET /api/providers/github-copilot/oauth/poll/{poll_id} -/// -/// Poll the status of a GitHub device flow. -/// Returns `pending`, `complete`, `expired`, `denied`, or `error`. -/// On `complete`, saves the token to secrets.env and sets GITHUB_TOKEN. -pub async fn copilot_oauth_poll( - State(state): State>, - Path(poll_id): Path, -) -> impl IntoResponse { - let flow = match COPILOT_FLOWS.get(&poll_id) { - Some(f) => f, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"status": "not_found", "error": "Unknown poll_id"})), - ) - } - }; - - if flow.expires_at <= Instant::now() { - drop(flow); - COPILOT_FLOWS.remove(&poll_id); - return ( - StatusCode::OK, - Json(serde_json::json!({"status": "expired"})), - ); - } - - let device_code = flow.device_code.clone(); - drop(flow); - - match openfang_runtime::copilot_oauth::poll_device_flow(&device_code).await { - openfang_runtime::copilot_oauth::DeviceFlowStatus::Pending => ( - StatusCode::OK, - Json(serde_json::json!({"status": "pending"})), - ), - openfang_runtime::copilot_oauth::DeviceFlowStatus::Complete { access_token } => { - // Save to secrets.env - let secrets_path = state.kernel.config.home_dir.join("secrets.env"); - if let Err(e) = write_secret_env(&secrets_path, "GITHUB_TOKEN", &access_token) { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"status": "error", "error": format!("Failed to save token: {e}")})), - ); - } - - // Set in current process - std::env::set_var("GITHUB_TOKEN", access_token.as_str()); - - // Refresh auth detection - state - .kernel - .model_catalog - .write() - .unwrap_or_else(|e| e.into_inner()) - .detect_auth(); - - // Clean up flow state - COPILOT_FLOWS.remove(&poll_id); - - ( - StatusCode::OK, - Json(serde_json::json!({"status": "complete"})), - ) - } - openfang_runtime::copilot_oauth::DeviceFlowStatus::SlowDown { new_interval } => { - // Update interval - if let Some(mut f) = COPILOT_FLOWS.get_mut(&poll_id) { - f.interval = new_interval; - } - ( - StatusCode::OK, - Json(serde_json::json!({"status": "pending", "interval": new_interval})), - ) - } - openfang_runtime::copilot_oauth::DeviceFlowStatus::Expired => { - COPILOT_FLOWS.remove(&poll_id); - ( - StatusCode::OK, - Json(serde_json::json!({"status": "expired"})), - ) - } - openfang_runtime::copilot_oauth::DeviceFlowStatus::AccessDenied => { - COPILOT_FLOWS.remove(&poll_id); - ( - StatusCode::OK, - Json(serde_json::json!({"status": "denied"})), - ) - } - openfang_runtime::copilot_oauth::DeviceFlowStatus::Error(e) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "error", "error": e})), - ), - } -} - -// --------------------------------------------------------------------------- -// Agent Communication (Comms) endpoints -// --------------------------------------------------------------------------- - -/// GET /api/comms/topology — Build agent topology graph from registry. -pub async fn comms_topology(State(state): State>) -> impl IntoResponse { - use openfang_types::comms::{EdgeKind, TopoEdge, TopoNode, Topology}; - - let agents = state.kernel.registry.list(); - - let nodes: Vec = agents - .iter() - .map(|e| TopoNode { - id: e.id.to_string(), - name: e.name.clone(), - state: format!("{:?}", e.state), - model: e.manifest.model.model.clone(), - }) - .collect(); - - let mut edges: Vec = Vec::new(); - - // Parent-child edges from registry - for agent in &agents { - for child_id in &agent.children { - edges.push(TopoEdge { - from: agent.id.to_string(), - to: child_id.to_string(), - kind: EdgeKind::ParentChild, - }); - } - } - - // Peer message edges from event bus history - let events = state.kernel.event_bus.history(500).await; - let mut peer_pairs = std::collections::HashSet::new(); - for event in &events { - if let openfang_types::event::EventPayload::Message(_) = &event.payload { - if let openfang_types::event::EventTarget::Agent(target_id) = &event.target { - let from = event.source.to_string(); - let to = target_id.to_string(); - // Deduplicate: only one edge per pair, skip self-loops - if from != to { - let key = if from < to { - (from.clone(), to.clone()) - } else { - (to.clone(), from.clone()) - }; - if peer_pairs.insert(key) { - edges.push(TopoEdge { - from, - to, - kind: EdgeKind::Peer, - }); - } - } - } - } - } - - Json(serde_json::to_value(Topology { nodes, edges }).unwrap_or_default()) -} - -/// Filter a kernel event into a CommsEvent, if it represents inter-agent communication. -fn filter_to_comms_event( - event: &openfang_types::event::Event, - agents: &[openfang_types::agent::AgentEntry], -) -> Option { - use openfang_types::comms::{CommsEvent, CommsEventKind}; - use openfang_types::event::{EventPayload, EventTarget, LifecycleEvent}; - - let resolve_name = |id: &str| -> String { - agents - .iter() - .find(|a| a.id.to_string() == id) - .map(|a| a.name.clone()) - .unwrap_or_else(|| id.to_string()) - }; - - match &event.payload { - EventPayload::Message(msg) => { - let target_id = match &event.target { - EventTarget::Agent(id) => id.to_string(), - _ => String::new(), - }; - Some(CommsEvent { - id: event.id.to_string(), - timestamp: event.timestamp.to_rfc3339(), - kind: CommsEventKind::AgentMessage, - source_id: event.source.to_string(), - source_name: resolve_name(&event.source.to_string()), - target_id: target_id.clone(), - target_name: resolve_name(&target_id), - detail: openfang_types::truncate_str(&msg.content, 200).to_string(), - }) - } - EventPayload::Lifecycle(lifecycle) => match lifecycle { - LifecycleEvent::Spawned { agent_id, name } => Some(CommsEvent { - id: event.id.to_string(), - timestamp: event.timestamp.to_rfc3339(), - kind: CommsEventKind::AgentSpawned, - source_id: event.source.to_string(), - source_name: resolve_name(&event.source.to_string()), - target_id: agent_id.to_string(), - target_name: name.clone(), - detail: format!("Agent '{}' spawned", name), - }), - LifecycleEvent::Terminated { agent_id, reason } => Some(CommsEvent { - id: event.id.to_string(), - timestamp: event.timestamp.to_rfc3339(), - kind: CommsEventKind::AgentTerminated, - source_id: event.source.to_string(), - source_name: resolve_name(&event.source.to_string()), - target_id: agent_id.to_string(), - target_name: resolve_name(&agent_id.to_string()), - detail: format!("Terminated: {}", reason), - }), - _ => None, - }, - _ => None, - } -} - -/// Convert an audit entry into a CommsEvent if it represents inter-agent activity. -fn audit_to_comms_event( - entry: &openfang_runtime::audit::AuditEntry, - agents: &[openfang_types::agent::AgentEntry], -) -> Option { - use openfang_types::comms::{CommsEvent, CommsEventKind}; - - let resolve_name = |id: &str| -> String { - agents - .iter() - .find(|a| a.id.to_string() == id) - .map(|a| a.name.clone()) - .unwrap_or_else(|| { - if id.is_empty() || id == "system" { - "system".to_string() - } else { - openfang_types::truncate_str(id, 12).to_string() - } - }) - }; - - let action_str = format!("{:?}", entry.action); - let (kind, detail, target_label) = match action_str.as_str() { - "AgentMessage" => { - // Format detail: "tokens_in=X, tokens_out=Y" → readable summary - let detail = if entry.detail.starts_with("tokens_in=") { - let parts: Vec<&str> = entry.detail.split(", ").collect(); - let in_tok = parts.first().and_then(|p| p.strip_prefix("tokens_in=")).unwrap_or("?"); - let out_tok = parts.get(1).and_then(|p| p.strip_prefix("tokens_out=")).unwrap_or("?"); - if entry.outcome == "ok" { - format!("{} in / {} out tokens", in_tok, out_tok) - } else { - format!("{} in / {} out — {}", in_tok, out_tok, openfang_types::truncate_str(&entry.outcome, 80)) - } - } else if entry.outcome != "ok" { - format!("{} — {}", openfang_types::truncate_str(&entry.detail, 80), openfang_types::truncate_str(&entry.outcome, 80)) - } else { - openfang_types::truncate_str(&entry.detail, 200).to_string() - }; - (CommsEventKind::AgentMessage, detail, "user") - } - "AgentSpawn" => ( - CommsEventKind::AgentSpawned, - format!("Agent spawned: {}", openfang_types::truncate_str(&entry.detail, 100)), - "", - ), - "AgentKill" => ( - CommsEventKind::AgentTerminated, - format!("Agent killed: {}", openfang_types::truncate_str(&entry.detail, 100)), - "", - ), - _ => return None, - }; - - Some(CommsEvent { - id: format!("audit-{}", entry.seq), - timestamp: entry.timestamp.clone(), - kind, - source_id: entry.agent_id.clone(), - source_name: resolve_name(&entry.agent_id), - target_id: if target_label.is_empty() { String::new() } else { target_label.to_string() }, - target_name: if target_label.is_empty() { String::new() } else { target_label.to_string() }, - detail, - }) -} - -/// GET /api/comms/events — Return recent inter-agent communication events. -/// -/// Sources from both the event bus (for lifecycle events with full context) -/// and the audit log (for message/spawn/kill events that are always captured). -pub async fn comms_events( - State(state): State>, - Query(params): Query>, -) -> impl IntoResponse { - let limit = params - .get("limit") - .and_then(|v| v.parse::().ok()) - .unwrap_or(100) - .min(500); - - let agents = state.kernel.registry.list(); - - // Primary source: event bus (has full source/target context) - let bus_events = state.kernel.event_bus.history(500).await; - let mut comms_events: Vec = bus_events - .iter() - .filter_map(|e| filter_to_comms_event(e, &agents)) - .collect(); - - // Secondary source: audit log (always populated, wider coverage) - let audit_entries = state.kernel.audit_log.recent(500); - let seen_ids: std::collections::HashSet = - comms_events.iter().map(|e| e.id.clone()).collect(); - - for entry in audit_entries.iter().rev() { - if let Some(ev) = audit_to_comms_event(entry, &agents) { - if !seen_ids.contains(&ev.id) { - comms_events.push(ev); - } - } - } - - // Sort by timestamp descending (newest first) - comms_events.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); - comms_events.truncate(limit); - - Json(comms_events) -} - -/// GET /api/comms/events/stream — SSE stream of inter-agent communication events. -/// -/// Polls the audit log every 500ms for new inter-agent events. -pub async fn comms_events_stream( - State(state): State>, -) -> axum::response::Response { - use axum::response::sse::{Event, KeepAlive, Sse}; - - let (tx, rx) = tokio::sync::mpsc::channel::< - Result, - >(256); - - tokio::spawn(async move { - let mut last_seq: u64 = { - let entries = state.kernel.audit_log.recent(1); - entries.last().map(|e| e.seq).unwrap_or(0) - }; - - loop { - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - let agents = state.kernel.registry.list(); - let entries = state.kernel.audit_log.recent(50); - - for entry in &entries { - if entry.seq <= last_seq { - continue; - } - if let Some(comms_event) = audit_to_comms_event(entry, &agents) { - let data = serde_json::to_string(&comms_event).unwrap_or_default(); - if tx.send(Ok(Event::default().data(data))).await.is_err() { - return; // Client disconnected - } - } - } - - if let Some(last) = entries.last() { - last_seq = last.seq; - } - } - }); - - let rx_stream = tokio_stream::wrappers::ReceiverStream::new(rx); - Sse::new(rx_stream) - .keep_alive( - KeepAlive::new() - .interval(std::time::Duration::from_secs(15)) - .text("ping"), - ) - .into_response() -} - -/// POST /api/comms/send — Send a message from one agent to another. -pub async fn comms_send( - State(state): State>, - Json(req): Json, -) -> impl IntoResponse { - // Validate from agent exists - let from_id: openfang_types::agent::AgentId = match req.from_agent_id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid from_agent_id"})), - ) - } - }; - if state.kernel.registry.get(from_id).is_none() { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Source agent not found"})), - ); - } - - // Validate to agent exists - let to_id: openfang_types::agent::AgentId = match req.to_agent_id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Invalid to_agent_id"})), - ) - } - }; - if state.kernel.registry.get(to_id).is_none() { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Target agent not found"})), - ); - } - - // SECURITY: Limit message size - if req.message.len() > 64 * 1024 { - return ( - StatusCode::PAYLOAD_TOO_LARGE, - Json(serde_json::json!({"error": "Message too large (max 64KB)"})), - ); - } - - match state.kernel.send_message(to_id, &req.message).await { - Ok(result) => ( - StatusCode::OK, - Json(serde_json::json!({ - "ok": true, - "response": result.response, - "input_tokens": result.total_usage.input_tokens, - "output_tokens": result.total_usage.output_tokens, - })), - ), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Message delivery failed: {e}")})), - ), - } -} - -/// POST /api/comms/task — Post a task to the agent task queue. -pub async fn comms_task( - State(state): State>, - Json(req): Json, -) -> impl IntoResponse { - if req.title.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Title is required"})), - ); - } - - match state - .kernel - .memory - .task_post( - &req.title, - &req.description, - req.assigned_to.as_deref(), - Some("ui-user"), - ) - .await - { - Ok(task_id) => ( - StatusCode::CREATED, - Json(serde_json::json!({ - "ok": true, - "task_id": task_id, - })), - ), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Failed to post task: {e}")})), - ), - } -} +//! Route handlers for the OpenFang API. + +use crate::types::*; +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; +use dashmap::DashMap; +use openfang_kernel::triggers::{TriggerId, TriggerPattern}; +use openfang_kernel::workflow::{ + ErrorMode, StepAgent, StepMode, Workflow, WorkflowId, WorkflowStep, +}; +use openfang_kernel::OpenFangKernel; +use openfang_runtime::kernel_handle::KernelHandle; +use openfang_runtime::tool_runner::builtin_tool_definitions; +use openfang_types::agent::{AgentId, AgentIdentity, AgentManifest}; +use std::collections::HashMap; +use std::sync::{Arc, LazyLock}; +use std::time::Instant; + +/// Shared application state. +/// +/// The kernel is wrapped in Arc so it can serve as both the main kernel +/// and the KernelHandle for inter-agent tool access. +pub struct AppState { + pub kernel: Arc, + pub started_at: Instant, + /// Optional peer registry for OFP mesh networking status. + pub peer_registry: Option>, + /// Channel bridge manager — held behind a Mutex so it can be swapped on hot-reload. + pub bridge_manager: tokio::sync::Mutex>, + /// Live channel config — updated on every hot-reload so list_channels() reflects reality. + pub channels_config: tokio::sync::RwLock, + /// Notify handle to trigger graceful HTTP server shutdown from the API. + pub shutdown_notify: Arc, + /// ClawHub response cache — prevents 429 rate limiting on rapid dashboard refreshes. + /// Maps cache key → (fetched_at, response_json) with 120s TTL. + pub clawhub_cache: DashMap, +} + +/// POST /api/agents — Spawn a new agent. +pub async fn spawn_agent( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + // SECURITY: Reject oversized manifests to prevent parser memory exhaustion. + const MAX_MANIFEST_SIZE: usize = 1024 * 1024; // 1MB + if req.manifest_toml.len() > MAX_MANIFEST_SIZE { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + Json(serde_json::json!({"error": "Manifest too large (max 1MB)"})), + ); + } + + // SECURITY: Verify Ed25519 signature when a signed manifest is provided + if let Some(ref signed_json) = req.signed_manifest { + match state.kernel.verify_signed_manifest(signed_json) { + Ok(verified_toml) => { + // Ensure the signed manifest matches the provided manifest_toml + if verified_toml.trim() != req.manifest_toml.trim() { + tracing::warn!("Signed manifest content does not match manifest_toml"); + return ( + StatusCode::BAD_REQUEST, + Json( + serde_json::json!({"error": "Signed manifest content does not match manifest_toml"}), + ), + ); + } + } + Err(e) => { + tracing::warn!("Manifest signature verification failed: {e}"); + state.kernel.audit_log.record( + "system", + openfang_runtime::audit::AuditAction::AuthAttempt, + "manifest signature verification failed", + format!("error: {e}"), + ); + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({"error": "Manifest signature verification failed"})), + ); + } + } + } + + let manifest: AgentManifest = match toml::from_str(&req.manifest_toml) { + Ok(m) => m, + Err(e) => { + tracing::warn!("Invalid manifest TOML: {e}"); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid manifest format"})), + ); + } + }; + + let name = manifest.name.clone(); + match state.kernel.spawn_agent(manifest) { + Ok(id) => ( + StatusCode::CREATED, + Json(serde_json::json!(SpawnResponse { + agent_id: id.to_string(), + name, + })), + ), + Err(e) => { + tracing::warn!("Spawn failed: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Agent spawn failed"})), + ) + } + } +} + +/// GET /api/agents — List all agents. +pub async fn list_agents(State(state): State>) -> impl IntoResponse { + // Snapshot catalog once for enrichment + let catalog = state.kernel.model_catalog.read().ok(); + let dm = &state.kernel.config.default_model; + + let agents: Vec = state + .kernel + .registry + .list() + .into_iter() + .map(|e| { + // Resolve "default" provider/model to actual kernel defaults + let provider = + if e.manifest.model.provider.is_empty() || e.manifest.model.provider == "default" { + dm.provider.as_str() + } else { + e.manifest.model.provider.as_str() + }; + let model = if e.manifest.model.model.is_empty() || e.manifest.model.model == "default" + { + dm.model.as_str() + } else { + e.manifest.model.model.as_str() + }; + + // Enrich from catalog + let (tier, auth_status) = catalog + .as_ref() + .map(|cat| { + let tier = cat + .find_model(model) + .map(|m| format!("{:?}", m.tier).to_lowercase()) + .unwrap_or_else(|| "unknown".to_string()); + let auth = cat + .get_provider(provider) + .map(|p| format!("{:?}", p.auth_status).to_lowercase()) + .unwrap_or_else(|| "unknown".to_string()); + (tier, auth) + }) + .unwrap_or(("unknown".to_string(), "unknown".to_string())); + + let ready = matches!(e.state, openfang_types::agent::AgentState::Running) + && auth_status != "missing"; + + serde_json::json!({ + "id": e.id.to_string(), + "name": e.name, + "state": format!("{:?}", e.state), + "mode": e.mode, + "created_at": e.created_at.to_rfc3339(), + "last_active": e.last_active.to_rfc3339(), + "model_provider": provider, + "model_name": model, + "model_tier": tier, + "auth_status": auth_status, + "ready": ready, + "profile": e.manifest.profile, + "identity": { + "emoji": e.identity.emoji, + "avatar_url": e.identity.avatar_url, + "color": e.identity.color, + }, + }) + }) + .collect(); + + Json(agents) +} + +/// Resolve uploaded file attachments into ContentBlock::Image blocks. +/// +/// Reads each file from the upload directory, base64-encodes it, and +/// returns image content blocks ready to insert into a session message. +pub fn resolve_attachments( + attachments: &[AttachmentRef], +) -> Vec { + use base64::Engine; + + let upload_dir = std::env::temp_dir().join("openfang_uploads"); + let mut blocks = Vec::new(); + + for att in attachments { + // Look up metadata from the upload registry + let meta = UPLOAD_REGISTRY.get(&att.file_id); + let content_type = if let Some(ref m) = meta { + m.content_type.clone() + } else if !att.content_type.is_empty() { + att.content_type.clone() + } else { + continue; // Skip unknown attachments + }; + + // Only process image types + if !content_type.starts_with("image/") { + continue; + } + + // Validate file_id is a UUID to prevent path traversal + if uuid::Uuid::parse_str(&att.file_id).is_err() { + continue; + } + + let file_path = upload_dir.join(&att.file_id); + match std::fs::read(&file_path) { + Ok(data) => { + let b64 = base64::engine::general_purpose::STANDARD.encode(&data); + blocks.push(openfang_types::message::ContentBlock::Image { + media_type: content_type, + data: b64, + }); + } + Err(e) => { + tracing::warn!(file_id = %att.file_id, error = %e, "Failed to read upload for attachment"); + } + } + } + + blocks +} + +/// Pre-insert image attachments into an agent's session so the LLM can see them. +/// +/// This injects image content blocks into the session BEFORE the kernel +/// adds the text user message, so the LLM receives: [..., User(images), User(text)]. +pub fn inject_attachments_into_session( + kernel: &OpenFangKernel, + agent_id: AgentId, + image_blocks: Vec, +) { + use openfang_types::message::{Message, MessageContent, Role}; + + let entry = match kernel.registry.get(agent_id) { + Some(e) => e, + None => return, + }; + + let mut session = match kernel.memory.get_session(entry.session_id) { + Ok(Some(s)) => s, + _ => openfang_memory::session::Session { + id: entry.session_id, + agent_id, + messages: Vec::new(), + context_window_tokens: 0, + label: None, + }, + }; + + session.messages.push(Message { + role: Role::User, + content: MessageContent::Blocks(image_blocks), + }); + + if let Err(e) = kernel.memory.save_session(&session) { + tracing::warn!(error = %e, "Failed to save session with image attachments"); + } +} + +/// POST /api/agents/:id/message — Send a message to an agent. +pub async fn send_message( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ); + } + }; + + // SECURITY: Reject oversized messages to prevent OOM / LLM token abuse. + const MAX_MESSAGE_SIZE: usize = 64 * 1024; // 64KB + if req.message.len() > MAX_MESSAGE_SIZE { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + Json(serde_json::json!({"error": "Message too large (max 64KB)"})), + ); + } + + // Resolve file attachments into image content blocks + if !req.attachments.is_empty() { + let image_blocks = resolve_attachments(&req.attachments); + if !image_blocks.is_empty() { + inject_attachments_into_session(&state.kernel, agent_id, image_blocks); + } + } + + let kernel_handle: Arc = state.kernel.clone() as Arc; + match state + .kernel + .send_message_with_handle(agent_id, &req.message, Some(kernel_handle)) + .await + { + Ok(result) => { + // Guard: ensure we never return an empty response to the client + let response = if result.response.trim().is_empty() { + format!( + "[The agent completed processing but returned no text response. ({} in / {} out | {} iter)]", + result.total_usage.input_tokens, + result.total_usage.output_tokens, + result.iterations, + ) + } else { + result.response + }; + ( + StatusCode::OK, + Json(serde_json::json!(MessageResponse { + response, + input_tokens: result.total_usage.input_tokens, + output_tokens: result.total_usage.output_tokens, + iterations: result.iterations, + cost_usd: result.cost_usd, + })), + ) + } + Err(e) => { + tracing::warn!("send_message failed for agent {id}: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Message delivery failed: {e}")})), + ) + } + } +} + +/// GET /api/agents/:id/session — Get agent session (conversation history). +pub async fn get_agent_session( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ); + } + }; + + let entry = match state.kernel.registry.get(agent_id) { + Some(e) => e, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ); + } + }; + + match state.kernel.memory.get_session(entry.session_id) { + Ok(Some(session)) => { + let messages = serialize_session_messages(&session.messages); + ( + StatusCode::OK, + Json(serde_json::json!({ + "session_id": session.id.0.to_string(), + "agent_id": session.agent_id.0.to_string(), + "message_count": session.messages.len(), + "context_window_tokens": session.context_window_tokens, + "label": session.label, + "messages": messages, + })), + ) + } + Ok(None) => ( + StatusCode::OK, + Json(serde_json::json!({ + "session_id": entry.session_id.0.to_string(), + "agent_id": agent_id.to_string(), + "message_count": 0, + "context_window_tokens": 0, + "messages": [], + })), + ), + Err(e) => { + tracing::warn!("Session load failed for agent {id}: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Session load failed"})), + ) + } + } +} + +/// DELETE /api/agents/:id — Kill an agent. +pub async fn kill_agent( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ); + } + }; + + match state.kernel.kill_agent(agent_id) { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "killed", "agent_id": id})), + ), + Err(e) => { + tracing::warn!("kill_agent failed for {id}: {e}"); + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found or already terminated"})), + ) + } + } +} + +/// GET /api/status — Kernel status. +pub async fn status(State(state): State>) -> impl IntoResponse { + let agents: Vec = state + .kernel + .registry + .list() + .into_iter() + .map(|e| { + serde_json::json!({ + "id": e.id.to_string(), + "name": e.name, + "state": format!("{:?}", e.state), + "mode": e.mode, + "created_at": e.created_at.to_rfc3339(), + "model_provider": e.manifest.model.provider, + "model_name": e.manifest.model.model, + "profile": e.manifest.profile, + }) + }) + .collect(); + + let uptime = state.started_at.elapsed().as_secs(); + let agent_count = agents.len(); + + Json(serde_json::json!({ + "status": "running", + "agent_count": agent_count, + "default_provider": state.kernel.config.default_model.provider, + "default_model": state.kernel.config.default_model.model, + "uptime_seconds": uptime, + "api_listen": state.kernel.config.api_listen, + "home_dir": state.kernel.config.home_dir.display().to_string(), + "log_level": state.kernel.config.log_level, + "network_enabled": state.kernel.config.network_enabled, + "agents": agents, + })) +} + +/// POST /api/shutdown — Graceful shutdown. +pub async fn shutdown(State(state): State>) -> impl IntoResponse { + tracing::info!("Shutdown requested via API"); + // SECURITY: Record shutdown in audit trail + state.kernel.audit_log.record( + "system", + openfang_runtime::audit::AuditAction::ConfigChange, + "shutdown requested via API", + "ok", + ); + state.kernel.shutdown(); + // Signal the HTTP server to initiate graceful shutdown so the process exits. + state.shutdown_notify.notify_one(); + Json(serde_json::json!({"status": "shutting_down"})) +} + +// --------------------------------------------------------------------------- +// Workflow routes +// --------------------------------------------------------------------------- + +/// POST /api/workflows — Register a new workflow. +pub async fn create_workflow( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + let name = req["name"].as_str().unwrap_or("unnamed").to_string(); + let description = req["description"].as_str().unwrap_or("").to_string(); + + let steps_json = match req["steps"].as_array() { + Some(s) => s, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing 'steps' array"})), + ); + } + }; + + let mut steps = Vec::new(); + for s in steps_json { + let step_name = s["name"].as_str().unwrap_or("step").to_string(); + let agent = if let Some(id) = s["agent_id"].as_str() { + StepAgent::ById { id: id.to_string() } + } else if let Some(name) = s["agent_name"].as_str() { + StepAgent::ByName { + name: name.to_string(), + } + } else { + return ( + StatusCode::BAD_REQUEST, + Json( + serde_json::json!({"error": format!("Step '{}' needs 'agent_id' or 'agent_name'", step_name)}), + ), + ); + }; + + let mode = match s["mode"].as_str().unwrap_or("sequential") { + "fan_out" => StepMode::FanOut, + "collect" => StepMode::Collect, + "conditional" => StepMode::Conditional { + condition: s["condition"].as_str().unwrap_or("").to_string(), + }, + "loop" => StepMode::Loop { + max_iterations: s["max_iterations"].as_u64().unwrap_or(5) as u32, + until: s["until"].as_str().unwrap_or("").to_string(), + }, + _ => StepMode::Sequential, + }; + + let error_mode = match s["error_mode"].as_str().unwrap_or("fail") { + "skip" => ErrorMode::Skip, + "retry" => ErrorMode::Retry { + max_retries: s["max_retries"].as_u64().unwrap_or(3) as u32, + }, + _ => ErrorMode::Fail, + }; + + steps.push(WorkflowStep { + name: step_name, + agent, + prompt_template: s["prompt"].as_str().unwrap_or("{{input}}").to_string(), + mode, + timeout_secs: s["timeout_secs"].as_u64().unwrap_or(120), + error_mode, + output_var: s["output_var"].as_str().map(String::from), + }); + } + + let workflow = Workflow { + id: WorkflowId::new(), + name, + description, + steps, + created_at: chrono::Utc::now(), + }; + + let id = state.kernel.register_workflow(workflow).await; + ( + StatusCode::CREATED, + Json(serde_json::json!({"workflow_id": id.to_string()})), + ) +} + +/// GET /api/workflows — List all workflows. +pub async fn list_workflows(State(state): State>) -> impl IntoResponse { + let workflows = state.kernel.workflows.list_workflows().await; + let list: Vec = workflows + .iter() + .map(|w| { + serde_json::json!({ + "id": w.id.to_string(), + "name": w.name, + "description": w.description, + "steps": w.steps.len(), + "created_at": w.created_at.to_rfc3339(), + }) + }) + .collect(); + Json(list) +} + +/// POST /api/workflows/:id/run — Execute a workflow. +pub async fn run_workflow( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let workflow_id = WorkflowId(match id.parse() { + Ok(u) => u, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid workflow ID"})), + ); + } + }); + + let input = req["input"].as_str().unwrap_or("").to_string(); + + match state.kernel.run_workflow(workflow_id, input).await { + Ok((run_id, output)) => ( + StatusCode::OK, + Json(serde_json::json!({ + "run_id": run_id.to_string(), + "output": output, + "status": "completed", + })), + ), + Err(e) => { + tracing::warn!("Workflow run failed for {id}: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Workflow execution failed"})), + ) + } + } +} + +/// GET /api/workflows/:id/runs — List runs for a workflow. +pub async fn list_workflow_runs( + State(state): State>, + Path(_id): Path, +) -> impl IntoResponse { + let runs = state.kernel.workflows.list_runs(None).await; + let list: Vec = runs + .iter() + .map(|r| { + serde_json::json!({ + "id": r.id.to_string(), + "workflow_name": r.workflow_name, + "state": serde_json::to_value(&r.state).unwrap_or_default(), + "steps_completed": r.step_results.len(), + "started_at": r.started_at.to_rfc3339(), + "completed_at": r.completed_at.map(|t| t.to_rfc3339()), + }) + }) + .collect(); + Json(list) +} + +// --------------------------------------------------------------------------- +// Trigger routes +// --------------------------------------------------------------------------- + +/// POST /api/triggers — Register a new event trigger. +pub async fn create_trigger( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + let agent_id_str = match req["agent_id"].as_str() { + Some(id) => id, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing 'agent_id'"})), + ); + } + }; + + let agent_id: AgentId = match agent_id_str.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent_id"})), + ); + } + }; + + let pattern: TriggerPattern = match req.get("pattern") { + Some(p) => match serde_json::from_value(p.clone()) { + Ok(pat) => pat, + Err(e) => { + tracing::warn!("Invalid trigger pattern: {e}"); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid trigger pattern"})), + ); + } + }, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing 'pattern'"})), + ); + } + }; + + let prompt_template = req["prompt_template"] + .as_str() + .unwrap_or("Event: {{event}}") + .to_string(); + let max_fires = req["max_fires"].as_u64().unwrap_or(0); + + match state + .kernel + .register_trigger(agent_id, pattern, prompt_template, max_fires) + { + Ok(trigger_id) => ( + StatusCode::CREATED, + Json(serde_json::json!({ + "trigger_id": trigger_id.to_string(), + "agent_id": agent_id.to_string(), + })), + ), + Err(e) => { + tracing::warn!("Trigger registration failed: {e}"); + ( + StatusCode::NOT_FOUND, + Json( + serde_json::json!({"error": "Trigger registration failed (agent not found?)"}), + ), + ) + } + } +} + +/// GET /api/triggers — List all triggers (optionally filter by ?agent_id=...). +pub async fn list_triggers( + State(state): State>, + Query(params): Query>, +) -> impl IntoResponse { + let agent_filter = params + .get("agent_id") + .and_then(|id| id.parse::().ok()); + + let triggers = state.kernel.list_triggers(agent_filter); + let list: Vec = triggers + .iter() + .map(|t| { + serde_json::json!({ + "id": t.id.to_string(), + "agent_id": t.agent_id.to_string(), + "pattern": serde_json::to_value(&t.pattern).unwrap_or_default(), + "prompt_template": t.prompt_template, + "enabled": t.enabled, + "fire_count": t.fire_count, + "max_fires": t.max_fires, + "created_at": t.created_at.to_rfc3339(), + }) + }) + .collect(); + Json(list) +} + +/// DELETE /api/triggers/:id — Remove a trigger. +pub async fn delete_trigger( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let trigger_id = TriggerId(match id.parse() { + Ok(u) => u, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid trigger ID"})), + ); + } + }); + + if state.kernel.remove_trigger(trigger_id) { + ( + StatusCode::OK, + Json(serde_json::json!({"status": "removed", "trigger_id": id})), + ) + } else { + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Trigger not found"})), + ) + } +} + +// --------------------------------------------------------------------------- +// Profile + Mode endpoints +// --------------------------------------------------------------------------- + +/// GET /api/profiles — List all tool profiles and their tool lists. +pub async fn list_profiles() -> impl IntoResponse { + use openfang_types::agent::ToolProfile; + + let profiles = [ + ("minimal", ToolProfile::Minimal), + ("coding", ToolProfile::Coding), + ("research", ToolProfile::Research), + ("messaging", ToolProfile::Messaging), + ("automation", ToolProfile::Automation), + ("full", ToolProfile::Full), + ]; + + let result: Vec = profiles + .iter() + .map(|(name, profile)| { + serde_json::json!({ + "name": name, + "tools": profile.tools(), + }) + }) + .collect(); + + Json(result) +} + +/// PUT /api/agents/:id/mode — Change an agent's operational mode. +pub async fn set_agent_mode( + State(state): State>, + Path(id): Path, + Json(body): Json, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ); + } + }; + + match state.kernel.registry.set_mode(agent_id, body.mode) { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "updated", + "agent_id": id, + "mode": body.mode, + })), + ), + Err(_) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ), + } +} + +// --------------------------------------------------------------------------- +// Version endpoint +// --------------------------------------------------------------------------- + +/// GET /api/version — Build & version info. +pub async fn version() -> impl IntoResponse { + Json(serde_json::json!({ + "name": "openfang", + "version": env!("CARGO_PKG_VERSION"), + "build_date": option_env!("BUILD_DATE").unwrap_or("dev"), + "git_sha": option_env!("GIT_SHA").unwrap_or("unknown"), + "rust_version": option_env!("RUSTC_VERSION").unwrap_or("unknown"), + "platform": std::env::consts::OS, + "arch": std::env::consts::ARCH, + })) +} + +// --------------------------------------------------------------------------- +// Single agent detail + SSE streaming +// --------------------------------------------------------------------------- + +/// GET /api/agents/:id — Get a single agent's detailed info. +pub async fn get_agent( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ); + } + }; + + let entry = match state.kernel.registry.get(agent_id) { + Some(e) => e, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ); + } + }; + + ( + StatusCode::OK, + Json(serde_json::json!({ + "id": entry.id.to_string(), + "name": entry.name, + "state": format!("{:?}", entry.state), + "mode": entry.mode, + "profile": entry.manifest.profile, + "created_at": entry.created_at.to_rfc3339(), + "session_id": entry.session_id.0.to_string(), + "model": { + "provider": entry.manifest.model.provider, + "model": entry.manifest.model.model, + }, + "capabilities": { + "tools": entry.manifest.capabilities.tools, + "network": entry.manifest.capabilities.network, + }, + "description": entry.manifest.description, + "tags": entry.manifest.tags, + "identity": { + "emoji": entry.identity.emoji, + "avatar_url": entry.identity.avatar_url, + "color": entry.identity.color, + }, + "skills": entry.manifest.skills, + "skills_mode": if entry.manifest.skills.is_empty() { "all" } else { "allowlist" }, + "mcp_servers": entry.manifest.mcp_servers, + "mcp_servers_mode": if entry.manifest.mcp_servers.is_empty() { "all" } else { "allowlist" }, + })), + ) +} + +/// POST /api/agents/:id/message/stream — SSE streaming response. +pub async fn send_message_stream( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> axum::response::Response { + use axum::response::sse::{Event, Sse}; + use futures::stream; + use openfang_runtime::llm_driver::StreamEvent; + + // SECURITY: Reject oversized messages to prevent OOM / LLM token abuse. + const MAX_MESSAGE_SIZE: usize = 64 * 1024; // 64KB + if req.message.len() > MAX_MESSAGE_SIZE { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + Json(serde_json::json!({"error": "Message too large (max 64KB)"})), + ) + .into_response(); + } + + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ) + .into_response(); + } + }; + + if state.kernel.registry.get(agent_id).is_none() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ) + .into_response(); + } + + let kernel_handle: Arc = state.kernel.clone() as Arc; + let (rx, _handle) = + match state + .kernel + .send_message_streaming(agent_id, &req.message, Some(kernel_handle)) + { + Ok(pair) => pair, + Err(e) => { + tracing::warn!("Streaming message failed for agent {id}: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Streaming message failed"})), + ) + .into_response(); + } + }; + + let sse_stream = stream::unfold(rx, |mut rx| async move { + match rx.recv().await { + Some(event) => { + let sse_event: Result = Ok(match event { + StreamEvent::TextDelta { text } => Event::default() + .event("chunk") + .json_data(serde_json::json!({"content": text, "done": false})) + .unwrap_or_else(|_| Event::default().data("error")), + StreamEvent::ToolUseStart { name, .. } => Event::default() + .event("tool_use") + .json_data(serde_json::json!({"tool": name})) + .unwrap_or_else(|_| Event::default().data("error")), + StreamEvent::ToolUseEnd { name, input, .. } => Event::default() + .event("tool_result") + .json_data(serde_json::json!({"tool": name, "input": input})) + .unwrap_or_else(|_| Event::default().data("error")), + StreamEvent::ContentComplete { usage, .. } => Event::default() + .event("done") + .json_data(serde_json::json!({ + "done": true, + "usage": { + "input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + } + })) + .unwrap_or_else(|_| Event::default().data("error")), + StreamEvent::PhaseChange { phase, detail } => Event::default() + .event("phase") + .json_data(serde_json::json!({ + "phase": phase, + "detail": detail, + })) + .unwrap_or_else(|_| Event::default().data("error")), + _ => Event::default().comment("skip"), + }); + Some((sse_event, rx)) + } + None => None, + } + }); + + Sse::new(sse_stream).into_response() +} + +// --------------------------------------------------------------------------- +// Channel status endpoints — data-driven registry for all 40 adapters +// --------------------------------------------------------------------------- + +/// Field type for the channel configuration form. +#[derive(Clone, Copy, PartialEq)] +enum FieldType { + Secret, + Text, + Number, + List, +} + +impl FieldType { + fn as_str(self) -> &'static str { + match self { + Self::Secret => "secret", + Self::Text => "text", + Self::Number => "number", + Self::List => "list", + } + } +} + +/// A single configurable field for a channel adapter. +#[derive(Clone)] +struct ChannelField { + key: &'static str, + label: &'static str, + field_type: FieldType, + env_var: Option<&'static str>, + required: bool, + placeholder: &'static str, + /// If true, this field is hidden under "Show Advanced" in the UI. + advanced: bool, +} + +/// Metadata for one channel adapter. +struct ChannelMeta { + name: &'static str, + display_name: &'static str, + icon: &'static str, + description: &'static str, + category: &'static str, + difficulty: &'static str, + setup_time: &'static str, + /// One-line quick setup hint shown in the simple form view. + quick_setup: &'static str, + /// Setup type: "form" (default), "qr" (QR code scan + form fallback). + setup_type: &'static str, + fields: &'static [ChannelField], + setup_steps: &'static [&'static str], + config_template: &'static str, +} + +const CHANNEL_REGISTRY: &[ChannelMeta] = &[ + // ── Messaging (12) ────────────────────────────────────────────── + ChannelMeta { + name: "telegram", display_name: "Telegram", icon: "TG", + description: "Telegram Bot API — long-polling adapter", + category: "messaging", difficulty: "Easy", setup_time: "~2 min", + quick_setup: "Paste your bot token from @BotFather", + setup_type: "form", + fields: &[ + ChannelField { key: "bot_token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("TELEGRAM_BOT_TOKEN"), required: true, placeholder: "123456:ABC-DEF...", advanced: false }, + ChannelField { key: "allowed_users", label: "Allowed User IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "12345, 67890", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ChannelField { key: "poll_interval_secs", label: "Poll Interval (sec)", field_type: FieldType::Number, env_var: None, required: false, placeholder: "1", advanced: true }, + ], + setup_steps: &["Open @BotFather on Telegram", "Send /newbot and follow the prompts", "Paste the token below"], + config_template: "[channels.telegram]\nbot_token_env = \"TELEGRAM_BOT_TOKEN\"", + }, + ChannelMeta { + name: "discord", display_name: "Discord", icon: "DC", + description: "Discord Gateway bot adapter", + category: "messaging", difficulty: "Easy", setup_time: "~3 min", + quick_setup: "Paste your bot token from the Discord Developer Portal", + setup_type: "form", + fields: &[ + ChannelField { key: "bot_token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("DISCORD_BOT_TOKEN"), required: true, placeholder: "MTIz...", advanced: false }, + ChannelField { key: "allowed_guilds", label: "Allowed Guild IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "123456789, 987654321", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ChannelField { key: "intents", label: "Intents Bitmask", field_type: FieldType::Number, env_var: None, required: false, placeholder: "37376", advanced: true }, + ], + setup_steps: &["Go to discord.com/developers/applications", "Create a bot and copy the token", "Paste it below"], + config_template: "[channels.discord]\nbot_token_env = \"DISCORD_BOT_TOKEN\"", + }, + ChannelMeta { + name: "slack", display_name: "Slack", icon: "SL", + description: "Slack Socket Mode + Events API", + category: "messaging", difficulty: "Medium", setup_time: "~5 min", + quick_setup: "Paste your App Token and Bot Token from api.slack.com", + setup_type: "form", + fields: &[ + ChannelField { key: "app_token_env", label: "App Token (xapp-)", field_type: FieldType::Secret, env_var: Some("SLACK_APP_TOKEN"), required: true, placeholder: "xapp-1-...", advanced: false }, + ChannelField { key: "bot_token_env", label: "Bot Token (xoxb-)", field_type: FieldType::Secret, env_var: Some("SLACK_BOT_TOKEN"), required: true, placeholder: "xoxb-...", advanced: false }, + ChannelField { key: "allowed_channels", label: "Allowed Channel IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "C01234, C56789", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create app at api.slack.com/apps", "Enable Socket Mode and copy App Token", "Copy Bot Token from OAuth & Permissions"], + config_template: "[channels.slack]\napp_token_env = \"SLACK_APP_TOKEN\"\nbot_token_env = \"SLACK_BOT_TOKEN\"", + }, + ChannelMeta { + name: "whatsapp", display_name: "WhatsApp", icon: "WA", + description: "Connect your personal WhatsApp via QR scan", + category: "messaging", difficulty: "Easy", setup_time: "~1 min", + quick_setup: "Scan QR code with your phone — no developer account needed", + setup_type: "qr", + fields: &[ + // Business API fallback fields — all advanced (hidden behind "Use Business API" toggle) + ChannelField { key: "access_token_env", label: "Access Token", field_type: FieldType::Secret, env_var: Some("WHATSAPP_ACCESS_TOKEN"), required: false, placeholder: "EAAx...", advanced: true }, + ChannelField { key: "phone_number_id", label: "Phone Number ID", field_type: FieldType::Text, env_var: None, required: false, placeholder: "1234567890", advanced: true }, + ChannelField { key: "verify_token_env", label: "Verify Token", field_type: FieldType::Secret, env_var: Some("WHATSAPP_VERIFY_TOKEN"), required: false, placeholder: "my-verify-token", advanced: true }, + ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8443", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Open WhatsApp on your phone", "Go to Linked Devices", "Tap Link a Device and scan the QR code"], + config_template: "[channels.whatsapp]\naccess_token_env = \"WHATSAPP_ACCESS_TOKEN\"\nphone_number_id = \"\"", + }, + ChannelMeta { + name: "signal", display_name: "Signal", icon: "SG", + description: "Signal via signal-cli REST API", + category: "messaging", difficulty: "Medium", setup_time: "~10 min", + quick_setup: "Enter your signal-cli API URL", + setup_type: "form", + fields: &[ + ChannelField { key: "api_url", label: "signal-cli API URL", field_type: FieldType::Text, env_var: None, required: true, placeholder: "http://localhost:8080", advanced: false }, + ChannelField { key: "phone_number", label: "Phone Number", field_type: FieldType::Text, env_var: None, required: true, placeholder: "+1234567890", advanced: false }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Install signal-cli-rest-api", "Enter the API URL and your phone number"], + config_template: "[channels.signal]\napi_url = \"http://localhost:8080\"\nphone_number = \"\"", + }, + ChannelMeta { + name: "matrix", display_name: "Matrix", icon: "MX", + description: "Matrix/Element bot via homeserver", + category: "messaging", difficulty: "Easy", setup_time: "~3 min", + quick_setup: "Paste your access token and homeserver URL", + setup_type: "form", + fields: &[ + ChannelField { key: "access_token_env", label: "Access Token", field_type: FieldType::Secret, env_var: Some("MATRIX_ACCESS_TOKEN"), required: true, placeholder: "syt_...", advanced: false }, + ChannelField { key: "homeserver_url", label: "Homeserver URL", field_type: FieldType::Text, env_var: None, required: true, placeholder: "https://matrix.org", advanced: false }, + ChannelField { key: "user_id", label: "Bot User ID", field_type: FieldType::Text, env_var: None, required: false, placeholder: "@openfang:matrix.org", advanced: true }, + ChannelField { key: "allowed_rooms", label: "Allowed Room IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "!abc:matrix.org", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create a bot account on your homeserver", "Generate an access token", "Paste token and homeserver URL below"], + config_template: "[channels.matrix]\naccess_token_env = \"MATRIX_ACCESS_TOKEN\"\nhomeserver_url = \"https://matrix.org\"", + }, + ChannelMeta { + name: "email", display_name: "Email", icon: "EM", + description: "IMAP/SMTP email adapter", + category: "messaging", difficulty: "Easy", setup_time: "~3 min", + quick_setup: "Enter your email, password, and server hosts", + setup_type: "form", + fields: &[ + ChannelField { key: "username", label: "Email Address", field_type: FieldType::Text, env_var: None, required: true, placeholder: "bot@example.com", advanced: false }, + ChannelField { key: "password_env", label: "Password / App Password", field_type: FieldType::Secret, env_var: Some("EMAIL_PASSWORD"), required: true, placeholder: "app-password", advanced: false }, + ChannelField { key: "imap_host", label: "IMAP Host", field_type: FieldType::Text, env_var: None, required: true, placeholder: "imap.gmail.com", advanced: false }, + ChannelField { key: "smtp_host", label: "SMTP Host", field_type: FieldType::Text, env_var: None, required: true, placeholder: "smtp.gmail.com", advanced: false }, + ChannelField { key: "imap_port", label: "IMAP Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "993", advanced: true }, + ChannelField { key: "smtp_port", label: "SMTP Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "587", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Enable IMAP on your email account", "Generate an app password if using Gmail", "Fill in email, password, and hosts below"], + config_template: "[channels.email]\nimap_host = \"imap.gmail.com\"\nsmtp_host = \"smtp.gmail.com\"\npassword_env = \"EMAIL_PASSWORD\"", + }, + ChannelMeta { + name: "line", display_name: "LINE", icon: "LN", + description: "LINE Messaging API adapter", + category: "messaging", difficulty: "Easy", setup_time: "~3 min", + quick_setup: "Paste your Channel Secret and Access Token", + setup_type: "form", + fields: &[ + ChannelField { key: "channel_secret_env", label: "Channel Secret", field_type: FieldType::Secret, env_var: Some("LINE_CHANNEL_SECRET"), required: true, placeholder: "abc123...", advanced: false }, + ChannelField { key: "access_token_env", label: "Channel Access Token", field_type: FieldType::Secret, env_var: Some("LINE_CHANNEL_ACCESS_TOKEN"), required: true, placeholder: "xyz789...", advanced: false }, + ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8450", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create a Messaging API channel at LINE Developers", "Copy Channel Secret and Access Token", "Paste them below"], + config_template: "[channels.line]\nchannel_secret_env = \"LINE_CHANNEL_SECRET\"\naccess_token_env = \"LINE_CHANNEL_ACCESS_TOKEN\"", + }, + ChannelMeta { + name: "viber", display_name: "Viber", icon: "VB", + description: "Viber Bot API adapter", + category: "messaging", difficulty: "Easy", setup_time: "~2 min", + quick_setup: "Paste your auth token from partners.viber.com", + setup_type: "form", + fields: &[ + ChannelField { key: "auth_token_env", label: "Auth Token", field_type: FieldType::Secret, env_var: Some("VIBER_AUTH_TOKEN"), required: true, placeholder: "4dc...", advanced: false }, + ChannelField { key: "webhook_url", label: "Webhook URL", field_type: FieldType::Text, env_var: None, required: false, placeholder: "https://your-domain.com/viber", advanced: true }, + ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8451", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create a bot at partners.viber.com", "Copy the auth token", "Paste it below"], + config_template: "[channels.viber]\nauth_token_env = \"VIBER_AUTH_TOKEN\"", + }, + ChannelMeta { + name: "messenger", display_name: "Messenger", icon: "FB", + description: "Facebook Messenger Platform adapter", + category: "messaging", difficulty: "Medium", setup_time: "~10 min", + quick_setup: "Paste your Page Access Token from developers.facebook.com", + setup_type: "form", + fields: &[ + ChannelField { key: "page_token_env", label: "Page Access Token", field_type: FieldType::Secret, env_var: Some("MESSENGER_PAGE_TOKEN"), required: true, placeholder: "EAAx...", advanced: false }, + ChannelField { key: "verify_token_env", label: "Verify Token", field_type: FieldType::Secret, env_var: Some("MESSENGER_VERIFY_TOKEN"), required: false, placeholder: "my-verify-token", advanced: true }, + ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8452", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create a Facebook App and add Messenger", "Generate a Page Access Token", "Paste it below"], + config_template: "[channels.messenger]\npage_token_env = \"MESSENGER_PAGE_TOKEN\"", + }, + ChannelMeta { + name: "threema", display_name: "Threema", icon: "3M", + description: "Threema Gateway adapter", + category: "messaging", difficulty: "Easy", setup_time: "~3 min", + quick_setup: "Paste your Gateway ID and API secret", + setup_type: "form", + fields: &[ + ChannelField { key: "secret_env", label: "API Secret", field_type: FieldType::Secret, env_var: Some("THREEMA_SECRET"), required: true, placeholder: "abc123...", advanced: false }, + ChannelField { key: "threema_id", label: "Gateway ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "*MYID01", advanced: false }, + ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8454", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Register at gateway.threema.ch", "Copy your ID and API secret", "Paste them below"], + config_template: "[channels.threema]\nthreema_id = \"\"\nsecret_env = \"THREEMA_SECRET\"", + }, + ChannelMeta { + name: "keybase", display_name: "Keybase", icon: "KB", + description: "Keybase chat bot adapter", + category: "messaging", difficulty: "Easy", setup_time: "~3 min", + quick_setup: "Enter your username and paper key", + setup_type: "form", + fields: &[ + ChannelField { key: "username", label: "Username", field_type: FieldType::Text, env_var: None, required: true, placeholder: "openfang_bot", advanced: false }, + ChannelField { key: "paperkey_env", label: "Paper Key", field_type: FieldType::Secret, env_var: Some("KEYBASE_PAPERKEY"), required: true, placeholder: "word1 word2 word3...", advanced: false }, + ChannelField { key: "allowed_teams", label: "Allowed Teams", field_type: FieldType::List, env_var: None, required: false, placeholder: "team1, team2", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create a Keybase bot account", "Generate a paper key", "Enter username and paper key below"], + config_template: "[channels.keybase]\nusername = \"\"\npaperkey_env = \"KEYBASE_PAPERKEY\"", + }, + // ── Social (5) ────────────────────────────────────────────────── + ChannelMeta { + name: "reddit", display_name: "Reddit", icon: "RD", + description: "Reddit API bot adapter", + category: "social", difficulty: "Medium", setup_time: "~5 min", + quick_setup: "Paste your Client ID, Secret, and bot credentials", + setup_type: "form", + fields: &[ + ChannelField { key: "client_id", label: "Client ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "abc123def", advanced: false }, + ChannelField { key: "client_secret_env", label: "Client Secret", field_type: FieldType::Secret, env_var: Some("REDDIT_CLIENT_SECRET"), required: true, placeholder: "abc123...", advanced: false }, + ChannelField { key: "username", label: "Bot Username", field_type: FieldType::Text, env_var: None, required: true, placeholder: "openfang_bot", advanced: false }, + ChannelField { key: "password_env", label: "Bot Password", field_type: FieldType::Secret, env_var: Some("REDDIT_PASSWORD"), required: true, placeholder: "password", advanced: false }, + ChannelField { key: "subreddits", label: "Subreddits", field_type: FieldType::List, env_var: None, required: false, placeholder: "openfang, rust", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create a Reddit app at reddit.com/prefs/apps (script type)", "Copy Client ID and Secret", "Enter bot credentials below"], + config_template: "[channels.reddit]\nclient_id = \"\"\nclient_secret_env = \"REDDIT_CLIENT_SECRET\"\nusername = \"\"\npassword_env = \"REDDIT_PASSWORD\"", + }, + ChannelMeta { + name: "mastodon", display_name: "Mastodon", icon: "MA", + description: "Mastodon Streaming API adapter", + category: "social", difficulty: "Easy", setup_time: "~2 min", + quick_setup: "Paste your access token from Settings > Development", + setup_type: "form", + fields: &[ + ChannelField { key: "access_token_env", label: "Access Token", field_type: FieldType::Secret, env_var: Some("MASTODON_ACCESS_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, + ChannelField { key: "instance_url", label: "Instance URL", field_type: FieldType::Text, env_var: None, required: true, placeholder: "https://mastodon.social", advanced: false }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Go to Settings > Development on your instance", "Create an app and copy the token", "Paste it below"], + config_template: "[channels.mastodon]\ninstance_url = \"https://mastodon.social\"\naccess_token_env = \"MASTODON_ACCESS_TOKEN\"", + }, + ChannelMeta { + name: "bluesky", display_name: "Bluesky", icon: "BS", + description: "Bluesky/AT Protocol adapter", + category: "social", difficulty: "Easy", setup_time: "~1 min", + quick_setup: "Enter your handle and app password", + setup_type: "form", + fields: &[ + ChannelField { key: "identifier", label: "Handle", field_type: FieldType::Text, env_var: None, required: true, placeholder: "user.bsky.social", advanced: false }, + ChannelField { key: "app_password_env", label: "App Password", field_type: FieldType::Secret, env_var: Some("BLUESKY_APP_PASSWORD"), required: true, placeholder: "xxxx-xxxx-xxxx-xxxx", advanced: false }, + ChannelField { key: "service_url", label: "PDS URL", field_type: FieldType::Text, env_var: None, required: false, placeholder: "https://bsky.social", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Go to Settings > App Passwords in Bluesky", "Create an app password", "Enter handle and password below"], + config_template: "[channels.bluesky]\nidentifier = \"\"\napp_password_env = \"BLUESKY_APP_PASSWORD\"", + }, + ChannelMeta { + name: "linkedin", display_name: "LinkedIn", icon: "LI", + description: "LinkedIn Messaging API adapter", + category: "social", difficulty: "Hard", setup_time: "~15 min", + quick_setup: "Paste your OAuth2 access token and Organization ID", + setup_type: "form", + fields: &[ + ChannelField { key: "access_token_env", label: "Access Token", field_type: FieldType::Secret, env_var: Some("LINKEDIN_ACCESS_TOKEN"), required: true, placeholder: "AQV...", advanced: false }, + ChannelField { key: "organization_id", label: "Organization ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "12345678", advanced: false }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create a LinkedIn App at linkedin.com/developers", "Generate an OAuth2 token", "Enter token and org ID below"], + config_template: "[channels.linkedin]\naccess_token_env = \"LINKEDIN_ACCESS_TOKEN\"\norganization_id = \"\"", + }, + ChannelMeta { + name: "nostr", display_name: "Nostr", icon: "NS", + description: "Nostr relay protocol adapter", + category: "social", difficulty: "Easy", setup_time: "~2 min", + quick_setup: "Paste your private key (nsec or hex)", + setup_type: "form", + fields: &[ + ChannelField { key: "private_key_env", label: "Private Key", field_type: FieldType::Secret, env_var: Some("NOSTR_PRIVATE_KEY"), required: true, placeholder: "nsec1...", advanced: false }, + ChannelField { key: "relays", label: "Relay URLs", field_type: FieldType::List, env_var: None, required: false, placeholder: "wss://relay.damus.io", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Generate or use an existing Nostr keypair", "Paste your private key below"], + config_template: "[channels.nostr]\nprivate_key_env = \"NOSTR_PRIVATE_KEY\"", + }, + // ── Enterprise (10) ───────────────────────────────────────────── + ChannelMeta { + name: "teams", display_name: "Microsoft Teams", icon: "MS", + description: "Teams Bot Framework adapter", + category: "enterprise", difficulty: "Medium", setup_time: "~10 min", + quick_setup: "Paste your Azure Bot App ID and Password", + setup_type: "form", + fields: &[ + ChannelField { key: "app_id", label: "App ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "00000000-0000-...", advanced: false }, + ChannelField { key: "app_password_env", label: "App Password", field_type: FieldType::Secret, env_var: Some("TEAMS_APP_PASSWORD"), required: true, placeholder: "abc123...", advanced: false }, + ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "3978", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create an Azure Bot registration", "Copy App ID and generate a password", "Paste them below"], + config_template: "[channels.teams]\napp_id = \"\"\napp_password_env = \"TEAMS_APP_PASSWORD\"", + }, + ChannelMeta { + name: "mattermost", display_name: "Mattermost", icon: "MM", + description: "Mattermost WebSocket adapter", + category: "enterprise", difficulty: "Easy", setup_time: "~2 min", + quick_setup: "Paste your bot token and server URL", + setup_type: "form", + fields: &[ + ChannelField { key: "server_url", label: "Server URL", field_type: FieldType::Text, env_var: None, required: true, placeholder: "https://mattermost.example.com", advanced: false }, + ChannelField { key: "token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("MATTERMOST_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, + ChannelField { key: "allowed_channels", label: "Allowed Channels", field_type: FieldType::List, env_var: None, required: false, placeholder: "abc123, def456", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create a bot in System Console > Bot Accounts", "Copy the token", "Enter server URL and token below"], + config_template: "[channels.mattermost]\nserver_url = \"\"\ntoken_env = \"MATTERMOST_TOKEN\"", + }, + ChannelMeta { + name: "google_chat", display_name: "Google Chat", icon: "GC", + description: "Google Chat service account adapter", + category: "enterprise", difficulty: "Hard", setup_time: "~15 min", + quick_setup: "Enter path to your service account JSON key", + setup_type: "form", + fields: &[ + ChannelField { key: "service_account_env", label: "Service Account JSON", field_type: FieldType::Secret, env_var: Some("GOOGLE_CHAT_SERVICE_ACCOUNT"), required: true, placeholder: "/path/to/key.json", advanced: false }, + ChannelField { key: "space_ids", label: "Space IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "spaces/AAAA", advanced: true }, + ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8444", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create a Google Cloud project with Chat API", "Download service account JSON key", "Enter the path below"], + config_template: "[channels.google_chat]\nservice_account_env = \"GOOGLE_CHAT_SERVICE_ACCOUNT\"", + }, + ChannelMeta { + name: "webex", display_name: "Webex", icon: "WX", + description: "Cisco Webex bot adapter", + category: "enterprise", difficulty: "Easy", setup_time: "~2 min", + quick_setup: "Paste your bot token from developer.webex.com", + setup_type: "form", + fields: &[ + ChannelField { key: "bot_token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("WEBEX_BOT_TOKEN"), required: true, placeholder: "NjI...", advanced: false }, + ChannelField { key: "allowed_rooms", label: "Allowed Rooms", field_type: FieldType::List, env_var: None, required: false, placeholder: "Y2lz...", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create a bot at developer.webex.com", "Copy the token", "Paste it below"], + config_template: "[channels.webex]\nbot_token_env = \"WEBEX_BOT_TOKEN\"", + }, + ChannelMeta { + name: "feishu", display_name: "Feishu/Lark", icon: "FS", + description: "Feishu/Lark Open Platform adapter", + category: "enterprise", difficulty: "Easy", setup_time: "~3 min", + quick_setup: "Paste your App ID and App Secret", + setup_type: "form", + fields: &[ + ChannelField { key: "app_id", label: "App ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "cli_abc123", advanced: false }, + ChannelField { key: "app_secret_env", label: "App Secret", field_type: FieldType::Secret, env_var: Some("FEISHU_APP_SECRET"), required: true, placeholder: "abc123...", advanced: false }, + ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8453", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create an app at open.feishu.cn", "Copy App ID and Secret", "Paste them below"], + config_template: "[channels.feishu]\napp_id = \"\"\napp_secret_env = \"FEISHU_APP_SECRET\"", + }, + ChannelMeta { + name: "dingtalk", display_name: "DingTalk", icon: "DT", + description: "DingTalk Robot API adapter", + category: "enterprise", difficulty: "Easy", setup_time: "~3 min", + quick_setup: "Paste your webhook token and signing secret", + setup_type: "form", + fields: &[ + ChannelField { key: "access_token_env", label: "Access Token", field_type: FieldType::Secret, env_var: Some("DINGTALK_ACCESS_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, + ChannelField { key: "secret_env", label: "Signing Secret", field_type: FieldType::Secret, env_var: Some("DINGTALK_SECRET"), required: true, placeholder: "SEC...", advanced: false }, + ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8457", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create a robot in your DingTalk group", "Copy the token and signing secret", "Paste them below"], + config_template: "[channels.dingtalk]\naccess_token_env = \"DINGTALK_ACCESS_TOKEN\"\nsecret_env = \"DINGTALK_SECRET\"", + }, + ChannelMeta { + name: "pumble", display_name: "Pumble", icon: "PB", + description: "Pumble bot adapter", + category: "enterprise", difficulty: "Easy", setup_time: "~1 min", + quick_setup: "Paste your bot token", + setup_type: "form", + fields: &[ + ChannelField { key: "bot_token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("PUMBLE_BOT_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, + ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8455", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create a bot in Pumble Integrations", "Copy the token", "Paste it below"], + config_template: "[channels.pumble]\nbot_token_env = \"PUMBLE_BOT_TOKEN\"", + }, + ChannelMeta { + name: "flock", display_name: "Flock", icon: "FL", + description: "Flock bot adapter", + category: "enterprise", difficulty: "Easy", setup_time: "~1 min", + quick_setup: "Paste your bot token", + setup_type: "form", + fields: &[ + ChannelField { key: "bot_token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("FLOCK_BOT_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, + ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8456", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Build an app in Flock App Store", "Copy the bot token", "Paste it below"], + config_template: "[channels.flock]\nbot_token_env = \"FLOCK_BOT_TOKEN\"", + }, + ChannelMeta { + name: "twist", display_name: "Twist", icon: "TW", + description: "Twist API v3 adapter", + category: "enterprise", difficulty: "Easy", setup_time: "~2 min", + quick_setup: "Paste your API token and workspace ID", + setup_type: "form", + fields: &[ + ChannelField { key: "token_env", label: "API Token", field_type: FieldType::Secret, env_var: Some("TWIST_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, + ChannelField { key: "workspace_id", label: "Workspace ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "12345", advanced: false }, + ChannelField { key: "allowed_channels", label: "Channel IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "123, 456", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create an integration in Twist Settings", "Copy the API token", "Enter token and workspace ID below"], + config_template: "[channels.twist]\ntoken_env = \"TWIST_TOKEN\"\nworkspace_id = \"\"", + }, + ChannelMeta { + name: "zulip", display_name: "Zulip", icon: "ZL", + description: "Zulip event queue adapter", + category: "enterprise", difficulty: "Easy", setup_time: "~2 min", + quick_setup: "Paste your API key, server URL, and bot email", + setup_type: "form", + fields: &[ + ChannelField { key: "server_url", label: "Server URL", field_type: FieldType::Text, env_var: None, required: true, placeholder: "https://chat.zulip.org", advanced: false }, + ChannelField { key: "bot_email", label: "Bot Email", field_type: FieldType::Text, env_var: None, required: true, placeholder: "bot@zulip.example.com", advanced: false }, + ChannelField { key: "api_key_env", label: "API Key", field_type: FieldType::Secret, env_var: Some("ZULIP_API_KEY"), required: true, placeholder: "abc123...", advanced: false }, + ChannelField { key: "streams", label: "Streams", field_type: FieldType::List, env_var: None, required: false, placeholder: "general, dev", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create a bot in Zulip Settings > Your Bots", "Copy the API key", "Enter server URL, bot email, and key below"], + config_template: "[channels.zulip]\nserver_url = \"\"\nbot_email = \"\"\napi_key_env = \"ZULIP_API_KEY\"", + }, + // ── Developer (9) ─────────────────────────────────────────────── + ChannelMeta { + name: "irc", display_name: "IRC", icon: "IR", + description: "IRC raw TCP adapter", + category: "developer", difficulty: "Easy", setup_time: "~2 min", + quick_setup: "Enter server and nickname", + setup_type: "form", + fields: &[ + ChannelField { key: "server", label: "Server", field_type: FieldType::Text, env_var: None, required: true, placeholder: "irc.libera.chat", advanced: false }, + ChannelField { key: "nick", label: "Nickname", field_type: FieldType::Text, env_var: None, required: true, placeholder: "openfang", advanced: false }, + ChannelField { key: "channels", label: "Channels", field_type: FieldType::List, env_var: None, required: false, placeholder: "#openfang, #general", advanced: false }, + ChannelField { key: "port", label: "Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "6667", advanced: true }, + ChannelField { key: "use_tls", label: "Use TLS", field_type: FieldType::Text, env_var: None, required: false, placeholder: "false", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Choose an IRC server", "Enter server, nick, and channels below"], + config_template: "[channels.irc]\nserver = \"irc.libera.chat\"\nnick = \"openfang\"", + }, + ChannelMeta { + name: "xmpp", display_name: "XMPP/Jabber", icon: "XM", + description: "XMPP/Jabber protocol adapter", + category: "developer", difficulty: "Easy", setup_time: "~3 min", + quick_setup: "Enter your JID and password", + setup_type: "form", + fields: &[ + ChannelField { key: "jid", label: "JID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "bot@jabber.org", advanced: false }, + ChannelField { key: "password_env", label: "Password", field_type: FieldType::Secret, env_var: Some("XMPP_PASSWORD"), required: true, placeholder: "password", advanced: false }, + ChannelField { key: "server", label: "Server", field_type: FieldType::Text, env_var: None, required: false, placeholder: "jabber.org", advanced: true }, + ChannelField { key: "port", label: "Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "5222", advanced: true }, + ChannelField { key: "rooms", label: "MUC Rooms", field_type: FieldType::List, env_var: None, required: false, placeholder: "room@conference.jabber.org", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create a bot account on your XMPP server", "Enter JID and password below"], + config_template: "[channels.xmpp]\njid = \"\"\npassword_env = \"XMPP_PASSWORD\"", + }, + ChannelMeta { + name: "gitter", display_name: "Gitter", icon: "GT", + description: "Gitter Streaming API adapter", + category: "developer", difficulty: "Easy", setup_time: "~2 min", + quick_setup: "Paste your auth token and room ID", + setup_type: "form", + fields: &[ + ChannelField { key: "token_env", label: "Auth Token", field_type: FieldType::Secret, env_var: Some("GITTER_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, + ChannelField { key: "room_id", label: "Room ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "abc123def456", advanced: false }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Get a token from developer.gitter.im", "Find your room ID", "Paste both below"], + config_template: "[channels.gitter]\ntoken_env = \"GITTER_TOKEN\"\nroom_id = \"\"", + }, + ChannelMeta { + name: "discourse", display_name: "Discourse", icon: "DS", + description: "Discourse forum API adapter", + category: "developer", difficulty: "Easy", setup_time: "~2 min", + quick_setup: "Paste your API key and forum URL", + setup_type: "form", + fields: &[ + ChannelField { key: "base_url", label: "Forum URL", field_type: FieldType::Text, env_var: None, required: true, placeholder: "https://forum.example.com", advanced: false }, + ChannelField { key: "api_key_env", label: "API Key", field_type: FieldType::Secret, env_var: Some("DISCOURSE_API_KEY"), required: true, placeholder: "abc123...", advanced: false }, + ChannelField { key: "api_username", label: "API Username", field_type: FieldType::Text, env_var: None, required: false, placeholder: "system", advanced: true }, + ChannelField { key: "categories", label: "Categories", field_type: FieldType::List, env_var: None, required: false, placeholder: "general, support", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Go to Admin > API > Keys", "Generate an API key", "Enter forum URL and key below"], + config_template: "[channels.discourse]\nbase_url = \"\"\napi_key_env = \"DISCOURSE_API_KEY\"", + }, + ChannelMeta { + name: "revolt", display_name: "Revolt", icon: "RV", + description: "Revolt bot adapter", + category: "developer", difficulty: "Easy", setup_time: "~1 min", + quick_setup: "Paste your bot token", + setup_type: "form", + fields: &[ + ChannelField { key: "bot_token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("REVOLT_BOT_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, + ChannelField { key: "api_url", label: "API URL", field_type: FieldType::Text, env_var: None, required: false, placeholder: "https://api.revolt.chat", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Go to Settings > My Bots in Revolt", "Create a bot and copy the token", "Paste it below"], + config_template: "[channels.revolt]\nbot_token_env = \"REVOLT_BOT_TOKEN\"", + }, + ChannelMeta { + name: "guilded", display_name: "Guilded", icon: "GD", + description: "Guilded bot adapter", + category: "developer", difficulty: "Easy", setup_time: "~1 min", + quick_setup: "Paste your bot token", + setup_type: "form", + fields: &[ + ChannelField { key: "bot_token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("GUILDED_BOT_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, + ChannelField { key: "server_ids", label: "Server IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "abc123", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Go to Server Settings > Bots in Guilded", "Create a bot and copy the token", "Paste it below"], + config_template: "[channels.guilded]\nbot_token_env = \"GUILDED_BOT_TOKEN\"", + }, + ChannelMeta { + name: "nextcloud", display_name: "Nextcloud Talk", icon: "NC", + description: "Nextcloud Talk REST adapter", + category: "developer", difficulty: "Easy", setup_time: "~2 min", + quick_setup: "Paste your server URL and auth token", + setup_type: "form", + fields: &[ + ChannelField { key: "server_url", label: "Server URL", field_type: FieldType::Text, env_var: None, required: true, placeholder: "https://cloud.example.com", advanced: false }, + ChannelField { key: "token_env", label: "Auth Token", field_type: FieldType::Secret, env_var: Some("NEXTCLOUD_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, + ChannelField { key: "allowed_rooms", label: "Room Tokens", field_type: FieldType::List, env_var: None, required: false, placeholder: "abc123", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create a bot user in Nextcloud", "Generate an app password", "Enter URL and token below"], + config_template: "[channels.nextcloud]\nserver_url = \"\"\ntoken_env = \"NEXTCLOUD_TOKEN\"", + }, + ChannelMeta { + name: "rocketchat", display_name: "Rocket.Chat", icon: "RC", + description: "Rocket.Chat REST adapter", + category: "developer", difficulty: "Easy", setup_time: "~2 min", + quick_setup: "Paste your server URL, user ID, and token", + setup_type: "form", + fields: &[ + ChannelField { key: "server_url", label: "Server URL", field_type: FieldType::Text, env_var: None, required: true, placeholder: "https://rocket.example.com", advanced: false }, + ChannelField { key: "user_id", label: "Bot User ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "abc123", advanced: false }, + ChannelField { key: "token_env", label: "Auth Token", field_type: FieldType::Secret, env_var: Some("ROCKETCHAT_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, + ChannelField { key: "allowed_channels", label: "Channel IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "GENERAL", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create a bot in Admin > Users", "Generate a personal access token", "Enter URL, user ID, and token below"], + config_template: "[channels.rocketchat]\nserver_url = \"\"\ntoken_env = \"ROCKETCHAT_TOKEN\"\nuser_id = \"\"", + }, + ChannelMeta { + name: "twitch", display_name: "Twitch", icon: "TV", + description: "Twitch IRC gateway adapter", + category: "developer", difficulty: "Easy", setup_time: "~2 min", + quick_setup: "Paste your OAuth token and enter channel name", + setup_type: "form", + fields: &[ + ChannelField { key: "oauth_token_env", label: "OAuth Token", field_type: FieldType::Secret, env_var: Some("TWITCH_OAUTH_TOKEN"), required: true, placeholder: "oauth:abc123...", advanced: false }, + ChannelField { key: "nick", label: "Bot Nickname", field_type: FieldType::Text, env_var: None, required: true, placeholder: "openfang", advanced: false }, + ChannelField { key: "channels", label: "Channels (no #)", field_type: FieldType::List, env_var: None, required: true, placeholder: "mychannel", advanced: false }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Generate an OAuth token at twitchapps.com/tmi", "Enter token, nick, and channel below"], + config_template: "[channels.twitch]\noauth_token_env = \"TWITCH_OAUTH_TOKEN\"\nnick = \"openfang\"", + }, + // ── Notifications (4) ─────────────────────────────────────────── + ChannelMeta { + name: "ntfy", display_name: "ntfy", icon: "NF", + description: "ntfy.sh pub/sub notification adapter", + category: "notifications", difficulty: "Easy", setup_time: "~1 min", + quick_setup: "Just enter a topic name", + setup_type: "form", + fields: &[ + ChannelField { key: "topic", label: "Topic", field_type: FieldType::Text, env_var: None, required: true, placeholder: "openfang-alerts", advanced: false }, + ChannelField { key: "server_url", label: "Server URL", field_type: FieldType::Text, env_var: None, required: false, placeholder: "https://ntfy.sh", advanced: true }, + ChannelField { key: "token_env", label: "Auth Token", field_type: FieldType::Secret, env_var: Some("NTFY_TOKEN"), required: false, placeholder: "tk_abc123...", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Pick a topic name", "Enter it below — that's it!"], + config_template: "[channels.ntfy]\ntopic = \"\"", + }, + ChannelMeta { + name: "gotify", display_name: "Gotify", icon: "GF", + description: "Gotify WebSocket notification adapter", + category: "notifications", difficulty: "Easy", setup_time: "~2 min", + quick_setup: "Paste your server URL and tokens", + setup_type: "form", + fields: &[ + ChannelField { key: "server_url", label: "Server URL", field_type: FieldType::Text, env_var: None, required: true, placeholder: "https://gotify.example.com", advanced: false }, + ChannelField { key: "app_token_env", label: "App Token (send)", field_type: FieldType::Secret, env_var: Some("GOTIFY_APP_TOKEN"), required: true, placeholder: "abc123...", advanced: false }, + ChannelField { key: "client_token_env", label: "Client Token (receive)", field_type: FieldType::Secret, env_var: Some("GOTIFY_CLIENT_TOKEN"), required: true, placeholder: "def456...", advanced: false }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create an app and a client in Gotify", "Copy both tokens", "Enter URL and tokens below"], + config_template: "[channels.gotify]\nserver_url = \"\"\napp_token_env = \"GOTIFY_APP_TOKEN\"\nclient_token_env = \"GOTIFY_CLIENT_TOKEN\"", + }, + ChannelMeta { + name: "webhook", display_name: "Webhook", icon: "WH", + description: "Generic HMAC-signed webhook adapter", + category: "notifications", difficulty: "Easy", setup_time: "~1 min", + quick_setup: "Optionally set an HMAC secret", + setup_type: "form", + fields: &[ + ChannelField { key: "secret_env", label: "HMAC Secret", field_type: FieldType::Secret, env_var: Some("WEBHOOK_SECRET"), required: false, placeholder: "my-secret", advanced: false }, + ChannelField { key: "listen_port", label: "Listen Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8460", advanced: true }, + ChannelField { key: "callback_url", label: "Callback URL", field_type: FieldType::Text, env_var: None, required: false, placeholder: "https://example.com/webhook", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Enter an HMAC secret (or leave blank)", "Click Save — that's it!"], + config_template: "[channels.webhook]\nsecret_env = \"WEBHOOK_SECRET\"", + }, + ChannelMeta { + name: "mumble", display_name: "Mumble", icon: "MB", + description: "Mumble text chat adapter", + category: "notifications", difficulty: "Easy", setup_time: "~2 min", + quick_setup: "Enter server host and username", + setup_type: "form", + fields: &[ + ChannelField { key: "host", label: "Host", field_type: FieldType::Text, env_var: None, required: true, placeholder: "mumble.example.com", advanced: false }, + ChannelField { key: "username", label: "Username", field_type: FieldType::Text, env_var: None, required: true, placeholder: "openfang", advanced: false }, + ChannelField { key: "password_env", label: "Server Password", field_type: FieldType::Secret, env_var: Some("MUMBLE_PASSWORD"), required: false, placeholder: "password", advanced: true }, + ChannelField { key: "port", label: "Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "64738", advanced: true }, + ChannelField { key: "channel", label: "Channel", field_type: FieldType::Text, env_var: None, required: false, placeholder: "Root", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Enter host and username below", "Optionally add a password"], + config_template: "[channels.mumble]\nhost = \"\"\nusername = \"openfang\"", + }, +]; + +/// Check if a channel is configured (has a `[channels.xxx]` section in config). +fn is_channel_configured(config: &openfang_types::config::ChannelsConfig, name: &str) -> bool { + match name { + "telegram" => config.telegram.is_some(), + "discord" => config.discord.is_some(), + "slack" => config.slack.is_some(), + "whatsapp" => config.whatsapp.is_some(), + "signal" => config.signal.is_some(), + "matrix" => config.matrix.is_some(), + "email" => config.email.is_some(), + "line" => config.line.is_some(), + "viber" => config.viber.is_some(), + "messenger" => config.messenger.is_some(), + "threema" => config.threema.is_some(), + "keybase" => config.keybase.is_some(), + "reddit" => config.reddit.is_some(), + "mastodon" => config.mastodon.is_some(), + "bluesky" => config.bluesky.is_some(), + "linkedin" => config.linkedin.is_some(), + "nostr" => config.nostr.is_some(), + "teams" => config.teams.is_some(), + "mattermost" => config.mattermost.is_some(), + "google_chat" => config.google_chat.is_some(), + "webex" => config.webex.is_some(), + "feishu" => config.feishu.is_some(), + "dingtalk" => config.dingtalk.is_some(), + "pumble" => config.pumble.is_some(), + "flock" => config.flock.is_some(), + "twist" => config.twist.is_some(), + "zulip" => config.zulip.is_some(), + "irc" => config.irc.is_some(), + "xmpp" => config.xmpp.is_some(), + "gitter" => config.gitter.is_some(), + "discourse" => config.discourse.is_some(), + "revolt" => config.revolt.is_some(), + "guilded" => config.guilded.is_some(), + "nextcloud" => config.nextcloud.is_some(), + "rocketchat" => config.rocketchat.is_some(), + "twitch" => config.twitch.is_some(), + "ntfy" => config.ntfy.is_some(), + "gotify" => config.gotify.is_some(), + "webhook" => config.webhook.is_some(), + "mumble" => config.mumble.is_some(), + _ => false, + } +} + +/// Build a JSON field descriptor, checking env var presence but never exposing secrets. +/// For non-secret fields, includes the actual config value from `config_values` if available. +fn build_field_json( + f: &ChannelField, + config_values: Option<&serde_json::Value>, +) -> serde_json::Value { + let has_value = f + .env_var + .map(|ev| std::env::var(ev).map(|v| !v.is_empty()).unwrap_or(false)) + .unwrap_or(false); + let mut field = serde_json::json!({ + "key": f.key, + "label": f.label, + "type": f.field_type.as_str(), + "env_var": f.env_var, + "required": f.required, + "has_value": has_value, + "placeholder": f.placeholder, + "advanced": f.advanced, + }); + // For non-secret fields, include the actual saved config value so the + // dashboard can pre-populate forms when editing existing configs. + if f.env_var.is_none() { + if let Some(obj) = config_values.and_then(|v| v.as_object()) { + if let Some(val) = obj.get(f.key) { + // Convert arrays to comma-separated string for list fields + let display_val = if f.field_type == FieldType::List { + if let Some(arr) = val.as_array() { + serde_json::Value::String( + arr.iter() + .filter_map(|v| { + v.as_str() + .map(|s| s.to_string()) + .or_else(|| Some(v.to_string())) + }) + .collect::>() + .join(", "), + ) + } else { + val.clone() + } + } else { + val.clone() + }; + field["value"] = display_val; + if !val.is_null() && val.as_str().map(|s| !s.is_empty()).unwrap_or(true) { + field["has_value"] = serde_json::Value::Bool(true); + } + } + } + } + field +} + +/// Find a channel definition by name. +fn find_channel_meta(name: &str) -> Option<&'static ChannelMeta> { + CHANNEL_REGISTRY.iter().find(|c| c.name == name) +} + +/// Serialize a channel's config to a JSON Value for pre-populating dashboard forms. +fn channel_config_values( + config: &openfang_types::config::ChannelsConfig, + name: &str, +) -> Option { + match name { + "telegram" => config + .telegram + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "discord" => config + .discord + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "slack" => config + .slack + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "whatsapp" => config + .whatsapp + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "signal" => config + .signal + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "matrix" => config + .matrix + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "email" => config + .email + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "teams" => config + .teams + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "mattermost" => config + .mattermost + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "irc" => config + .irc + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "google_chat" => config + .google_chat + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "twitch" => config + .twitch + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "rocketchat" => config + .rocketchat + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "zulip" => config + .zulip + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "xmpp" => config + .xmpp + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "line" => config + .line + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "viber" => config + .viber + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "messenger" => config + .messenger + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "reddit" => config + .reddit + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "mastodon" => config + .mastodon + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "bluesky" => config + .bluesky + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "feishu" => config + .feishu + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "revolt" => config + .revolt + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "nextcloud" => config + .nextcloud + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "guilded" => config + .guilded + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "keybase" => config + .keybase + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "threema" => config + .threema + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "nostr" => config + .nostr + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "webex" => config + .webex + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "pumble" => config + .pumble + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "flock" => config + .flock + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "twist" => config + .twist + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "mumble" => config + .mumble + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "dingtalk" => config + .dingtalk + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "discourse" => config + .discourse + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "gitter" => config + .gitter + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "ntfy" => config + .ntfy + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "gotify" => config + .gotify + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "webhook" => config + .webhook + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "linkedin" => config + .linkedin + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + _ => None, + } +} + +/// GET /api/channels — List all 40 channel adapters with status and field metadata. +pub async fn list_channels(State(state): State>) -> impl IntoResponse { + // Read the live channels config (updated on every hot-reload) instead of the + // stale boot-time kernel.config, so newly configured channels show correctly. + let live_channels = state.channels_config.read().await; + let mut channels = Vec::new(); + let mut configured_count = 0u32; + + for meta in CHANNEL_REGISTRY { + let configured = is_channel_configured(&live_channels, meta.name); + if configured { + configured_count += 1; + } + + // Check if all required secret env vars are set + let has_token = meta + .fields + .iter() + .filter(|f| f.required && f.env_var.is_some()) + .all(|f| { + f.env_var + .map(|ev| std::env::var(ev).map(|v| !v.is_empty()).unwrap_or(false)) + .unwrap_or(true) + }); + + let config_vals = channel_config_values(&live_channels, meta.name); + let fields: Vec = meta + .fields + .iter() + .map(|f| build_field_json(f, config_vals.as_ref())) + .collect(); + + channels.push(serde_json::json!({ + "name": meta.name, + "display_name": meta.display_name, + "icon": meta.icon, + "description": meta.description, + "category": meta.category, + "difficulty": meta.difficulty, + "setup_time": meta.setup_time, + "quick_setup": meta.quick_setup, + "setup_type": meta.setup_type, + "configured": configured, + "has_token": has_token, + "fields": fields, + "setup_steps": meta.setup_steps, + "config_template": meta.config_template, + })); + } + + Json(serde_json::json!({ + "channels": channels, + "total": channels.len(), + "configured_count": configured_count, + })) +} + +/// POST /api/channels/{name}/configure — Save channel secrets + config fields. +pub async fn configure_channel( + State(state): State>, + Path(name): Path, + Json(body): Json, +) -> impl IntoResponse { + let meta = match find_channel_meta(&name) { + Some(m) => m, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Unknown channel"})), + ) + } + }; + + let fields = match body.get("fields").and_then(|v| v.as_object()) { + Some(f) => f, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing 'fields' object"})), + ) + } + }; + + let home = openfang_kernel::config::openfang_home(); + let secrets_path = home.join("secrets.env"); + let config_path = home.join("config.toml"); + let mut config_fields: HashMap = HashMap::new(); + + for field_def in meta.fields { + let value = fields + .get(field_def.key) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if value.is_empty() { + continue; + } + + if let Some(env_var) = field_def.env_var { + // Secret field — write to secrets.env and set in process + if let Err(e) = write_secret_env(&secrets_path, env_var, value) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Failed to write secret: {e}")})), + ); + } + // SAFETY: We are the only writer; this is a single-threaded config operation + unsafe { + std::env::set_var(env_var, value); + } + // Also write the env var NAME to config.toml so the channel section + // is not empty and the kernel knows which env var to read. + config_fields.insert( + field_def.key.to_string(), + (env_var.to_string(), FieldType::Text), + ); + } else { + // Config field — collect for TOML write with type info + config_fields.insert( + field_def.key.to_string(), + (value.to_string(), field_def.field_type), + ); + } + } + + // Write config.toml section + if let Err(e) = upsert_channel_config(&config_path, &name, &config_fields) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Failed to write config: {e}")})), + ); + } + + // Hot-reload: activate the channel immediately + match crate::channel_bridge::reload_channels_from_disk(&state).await { + Ok(started) => { + let activated = started.iter().any(|s| s.eq_ignore_ascii_case(&name)); + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "configured", + "channel": name, + "activated": activated, + "started_channels": started, + "note": if activated { + format!("{} activated successfully.", name) + } else { + "Channel configured but could not start (check credentials).".to_string() + } + })), + ) + } + Err(e) => { + tracing::warn!(error = %e, "Channel hot-reload failed after configure"); + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "configured", + "channel": name, + "activated": false, + "note": format!("Configured, but hot-reload failed: {e}. Restart daemon to activate.") + })), + ) + } + } +} + +/// DELETE /api/channels/{name}/configure — Remove channel secrets + config section. +pub async fn remove_channel( + State(state): State>, + Path(name): Path, +) -> impl IntoResponse { + let meta = match find_channel_meta(&name) { + Some(m) => m, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Unknown channel"})), + ) + } + }; + + let home = openfang_kernel::config::openfang_home(); + let secrets_path = home.join("secrets.env"); + let config_path = home.join("config.toml"); + + // Remove all secret env vars for this channel + for field_def in meta.fields { + if let Some(env_var) = field_def.env_var { + let _ = remove_secret_env(&secrets_path, env_var); + // SAFETY: Single-threaded config operation + unsafe { + std::env::remove_var(env_var); + } + } + } + + // Remove config section + if let Err(e) = remove_channel_config(&config_path, &name) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Failed to remove config: {e}")})), + ); + } + + // Hot-reload: deactivate the channel immediately + match crate::channel_bridge::reload_channels_from_disk(&state).await { + Ok(started) => ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "removed", + "channel": name, + "remaining_channels": started, + "note": format!("{} deactivated.", name) + })), + ), + Err(e) => { + tracing::warn!(error = %e, "Channel hot-reload failed after remove"); + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "removed", + "channel": name, + "note": format!("Removed, but hot-reload failed: {e}. Restart daemon to fully deactivate.") + })), + ) + } + } +} + +/// POST /api/channels/{name}/test — Basic connectivity check for a channel. +pub async fn test_channel(Path(name): Path) -> impl IntoResponse { + let meta = match find_channel_meta(&name) { + Some(m) => m, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"status": "error", "message": "Unknown channel"})), + ) + } + }; + + // Check all required env vars are set + let mut missing = Vec::new(); + for field_def in meta.fields { + if field_def.required { + if let Some(env_var) = field_def.env_var { + if std::env::var(env_var).map(|v| v.is_empty()).unwrap_or(true) { + missing.push(env_var); + } + } + } + } + + if !missing.is_empty() { + return ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "error", + "message": format!("Missing required env vars: {}", missing.join(", ")) + })), + ); + } + + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "ok", + "message": format!("All required credentials for {} are set.", meta.display_name) + })), + ) +} + +/// POST /api/channels/reload — Manually trigger a channel hot-reload from disk config. +pub async fn reload_channels(State(state): State>) -> impl IntoResponse { + match crate::channel_bridge::reload_channels_from_disk(&state).await { + Ok(started) => ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "ok", + "started": started, + })), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "status": "error", + "error": e, + })), + ), + } +} + +// --------------------------------------------------------------------------- +// WhatsApp QR login flow (OpenClaw-style) +// --------------------------------------------------------------------------- + +/// POST /api/channels/whatsapp/qr/start — Start a WhatsApp Web QR login session. +/// +/// If a WhatsApp Web gateway is available (e.g. a Baileys-based bridge process), +/// this proxies the request and returns a base64 QR code data URL. If no gateway +/// is running, it returns instructions to set one up. +pub async fn whatsapp_qr_start() -> impl IntoResponse { + // Check for WhatsApp Web gateway URL in config or env + let gateway_url = std::env::var("WHATSAPP_WEB_GATEWAY_URL").unwrap_or_default(); + + if gateway_url.is_empty() { + return Json(serde_json::json!({ + "available": false, + "message": "WhatsApp Web gateway not running. Start the gateway or use Business API mode.", + "help": "Run: npx openfang-whatsapp-gateway (or set WHATSAPP_WEB_GATEWAY_URL)" + })); + } + + // Try to reach the gateway and start a QR session. + // Uses a raw HTTP request via tokio TcpStream to avoid adding reqwest as a runtime dep. + let start_url = format!("{}/login/start", gateway_url.trim_end_matches('/')); + match gateway_http_post(&start_url).await { + Ok(body) => { + let qr_url = body + .get("qr_data_url") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + let sid = body + .get("session_id") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + let msg = body + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("Scan this QR code with WhatsApp → Linked Devices"); + let connected = body + .get("connected") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + Json(serde_json::json!({ + "available": true, + "qr_data_url": qr_url, + "session_id": sid, + "message": msg, + "connected": connected, + })) + } + Err(e) => Json(serde_json::json!({ + "available": false, + "message": format!("Could not reach WhatsApp Web gateway: {e}"), + "help": "Make sure the gateway is running at the configured URL" + })), + } +} + +/// GET /api/channels/whatsapp/qr/status — Poll for QR scan completion. +/// +/// After calling `/qr/start`, the frontend polls this to check if the user +/// has scanned the QR code and the WhatsApp Web session is connected. +pub async fn whatsapp_qr_status( + axum::extract::Query(params): axum::extract::Query>, +) -> impl IntoResponse { + let gateway_url = std::env::var("WHATSAPP_WEB_GATEWAY_URL").unwrap_or_default(); + + if gateway_url.is_empty() { + return Json(serde_json::json!({ + "connected": false, + "message": "Gateway not available" + })); + } + + let session_id = params.get("session_id").cloned().unwrap_or_default(); + let status_url = format!( + "{}/login/status?session_id={}", + gateway_url.trim_end_matches('/'), + session_id + ); + + match gateway_http_get(&status_url).await { + Ok(body) => { + let connected = body + .get("connected") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let msg = body + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("Waiting for scan..."); + let expired = body + .get("expired") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + Json(serde_json::json!({ + "connected": connected, + "message": msg, + "expired": expired, + })) + } + Err(_) => Json(serde_json::json!({ "connected": false, "message": "Gateway unreachable" })), + } +} + +/// Lightweight HTTP POST to a gateway URL. Returns parsed JSON body. +async fn gateway_http_post(url_with_path: &str) -> Result { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + // Split into base URL + path from the full URL like "http://127.0.0.1:3009/login/start" + let without_scheme = url_with_path + .strip_prefix("http://") + .or_else(|| url_with_path.strip_prefix("https://")) + .unwrap_or(url_with_path); + let (host_port, path) = if let Some(idx) = without_scheme.find('/') { + (&without_scheme[..idx], &without_scheme[idx..]) + } else { + (without_scheme, "/") + }; + let (host, port) = if let Some((h, p)) = host_port.rsplit_once(':') { + (h, p.parse().unwrap_or(3009u16)) + } else { + (host_port, 3009u16) + }; + + let mut stream = tokio::net::TcpStream::connect(format!("{host}:{port}")) + .await + .map_err(|e| format!("Connect failed: {e}"))?; + + let req = format!( + "POST {path} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}" + ); + stream + .write_all(req.as_bytes()) + .await + .map_err(|e| format!("Write failed: {e}"))?; + + let mut buf = Vec::new(); + stream + .read_to_end(&mut buf) + .await + .map_err(|e| format!("Read failed: {e}"))?; + let response = String::from_utf8_lossy(&buf); + + // Find the JSON body after the blank line separating headers from body + if let Some(idx) = response.find("\r\n\r\n") { + let body_str = &response[idx + 4..]; + serde_json::from_str(body_str.trim()).map_err(|e| format!("Parse failed: {e}")) + } else { + Err("No HTTP body in response".to_string()) + } +} + +/// Lightweight HTTP GET to a gateway URL. Returns parsed JSON body. +async fn gateway_http_get(url_with_path: &str) -> Result { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let without_scheme = url_with_path + .strip_prefix("http://") + .or_else(|| url_with_path.strip_prefix("https://")) + .unwrap_or(url_with_path); + let (host_port, path_and_query) = if let Some(idx) = without_scheme.find('/') { + (&without_scheme[..idx], &without_scheme[idx..]) + } else { + (without_scheme, "/") + }; + let (host, port) = if let Some((h, p)) = host_port.rsplit_once(':') { + (h, p.parse().unwrap_or(3009u16)) + } else { + (host_port, 3009u16) + }; + + let mut stream = tokio::net::TcpStream::connect(format!("{host}:{port}")) + .await + .map_err(|e| format!("Connect failed: {e}"))?; + + let req = format!( + "GET {path_and_query} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n" + ); + stream + .write_all(req.as_bytes()) + .await + .map_err(|e| format!("Write failed: {e}"))?; + + let mut buf = Vec::new(); + stream + .read_to_end(&mut buf) + .await + .map_err(|e| format!("Read failed: {e}"))?; + let response = String::from_utf8_lossy(&buf); + + if let Some(idx) = response.find("\r\n\r\n") { + let body_str = &response[idx + 4..]; + serde_json::from_str(body_str.trim()).map_err(|e| format!("Parse failed: {e}")) + } else { + Err("No HTTP body in response".to_string()) + } +} + +// --------------------------------------------------------------------------- +// Template endpoints +// --------------------------------------------------------------------------- + +/// GET /api/templates — List available agent templates. +pub async fn list_templates() -> impl IntoResponse { + let agents_dir = openfang_kernel::config::openfang_home().join("agents"); + let mut templates = Vec::new(); + + if let Ok(entries) = std::fs::read_dir(&agents_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let manifest_path = path.join("agent.toml"); + if manifest_path.exists() { + let name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + let description = std::fs::read_to_string(&manifest_path) + .ok() + .and_then(|content| toml::from_str::(&content).ok()) + .map(|m| m.description) + .unwrap_or_default(); + + templates.push(serde_json::json!({ + "name": name, + "description": description, + })); + } + } + } + } + + Json(serde_json::json!({ + "templates": templates, + "total": templates.len(), + })) +} + +/// GET /api/templates/:name — Get template details. +pub async fn get_template(Path(name): Path) -> impl IntoResponse { + let agents_dir = openfang_kernel::config::openfang_home().join("agents"); + let manifest_path = agents_dir.join(&name).join("agent.toml"); + + if !manifest_path.exists() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Template not found"})), + ); + } + + match std::fs::read_to_string(&manifest_path) { + Ok(content) => match toml::from_str::(&content) { + Ok(manifest) => ( + StatusCode::OK, + Json(serde_json::json!({ + "name": name, + "manifest": { + "name": manifest.name, + "description": manifest.description, + "module": manifest.module, + "tags": manifest.tags, + "model": { + "provider": manifest.model.provider, + "model": manifest.model.model, + }, + "capabilities": { + "tools": manifest.capabilities.tools, + "network": manifest.capabilities.network, + }, + }, + "manifest_toml": content, + })), + ), + Err(e) => { + tracing::warn!("Invalid template manifest for '{name}': {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Invalid template manifest"})), + ) + } + }, + Err(e) => { + tracing::warn!("Failed to read template '{name}': {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Failed to read template"})), + ) + } + } +} + +// --------------------------------------------------------------------------- +// Memory endpoints +// --------------------------------------------------------------------------- + +/// GET /api/memory/agents/:id/kv — List KV pairs for an agent. +/// +/// Note: memory_store tool writes to a shared namespace, so we read from that +/// same namespace regardless of which agent ID is in the URL. +pub async fn get_agent_kv( + State(state): State>, + Path(_id): Path, +) -> impl IntoResponse { + let agent_id = openfang_kernel::kernel::shared_memory_agent_id(); + + match state.kernel.memory.list_kv(agent_id) { + Ok(pairs) => { + let kv: Vec = pairs + .into_iter() + .map(|(k, v)| serde_json::json!({"key": k, "value": v})) + .collect(); + (StatusCode::OK, Json(serde_json::json!({"kv_pairs": kv}))) + } + Err(e) => { + tracing::warn!("Memory list_kv failed: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Memory operation failed"})), + ) + } + } +} + +/// GET /api/memory/agents/:id/kv/:key — Get a specific KV value. +pub async fn get_agent_kv_key( + State(state): State>, + Path((_id, key)): Path<(String, String)>, +) -> impl IntoResponse { + let agent_id = openfang_kernel::kernel::shared_memory_agent_id(); + + match state.kernel.memory.structured_get(agent_id, &key) { + Ok(Some(val)) => ( + StatusCode::OK, + Json(serde_json::json!({"key": key, "value": val})), + ), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Key not found"})), + ), + Err(e) => { + tracing::warn!("Memory get failed for key '{key}': {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Memory operation failed"})), + ) + } + } +} + +/// PUT /api/memory/agents/:id/kv/:key — Set a KV value. +pub async fn set_agent_kv_key( + State(state): State>, + Path((_id, key)): Path<(String, String)>, + Json(body): Json, +) -> impl IntoResponse { + let agent_id = openfang_kernel::kernel::shared_memory_agent_id(); + + let value = body.get("value").cloned().unwrap_or(body); + + match state.kernel.memory.structured_set(agent_id, &key, value) { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "stored", "key": key})), + ), + Err(e) => { + tracing::warn!("Memory set failed for key '{key}': {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Memory operation failed"})), + ) + } + } +} + +/// DELETE /api/memory/agents/:id/kv/:key — Delete a KV value. +pub async fn delete_agent_kv_key( + State(state): State>, + Path((_id, key)): Path<(String, String)>, +) -> impl IntoResponse { + let agent_id = openfang_kernel::kernel::shared_memory_agent_id(); + + match state.kernel.memory.structured_delete(agent_id, &key) { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "deleted", "key": key})), + ), + Err(e) => { + tracing::warn!("Memory delete failed for key '{key}': {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Memory operation failed"})), + ) + } + } +} + +/// GET /api/health — Minimal liveness probe (public, no auth required). +/// Returns only status and version to prevent information leakage. +/// Use GET /api/health/detail for full diagnostics (requires auth). +pub async fn health(State(state): State>) -> impl IntoResponse { + // Check database connectivity + let shared_id = openfang_types::agent::AgentId(uuid::Uuid::from_bytes([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + ])); + let db_ok = state + .kernel + .memory + .structured_get(shared_id, "__health_check__") + .is_ok(); + + let status = if db_ok { "ok" } else { "degraded" }; + + Json(serde_json::json!({ + "status": status, + "version": env!("CARGO_PKG_VERSION"), + })) +} + +/// GET /api/health/detail — Full health diagnostics (requires auth). +pub async fn health_detail(State(state): State>) -> impl IntoResponse { + let health = state.kernel.supervisor.health(); + + let shared_id = openfang_types::agent::AgentId(uuid::Uuid::from_bytes([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + ])); + let db_ok = state + .kernel + .memory + .structured_get(shared_id, "__health_check__") + .is_ok(); + + let config_warnings = state.kernel.config.validate(); + let status = if db_ok { "ok" } else { "degraded" }; + + Json(serde_json::json!({ + "status": status, + "version": env!("CARGO_PKG_VERSION"), + "uptime_seconds": state.started_at.elapsed().as_secs(), + "panic_count": health.panic_count, + "restart_count": health.restart_count, + "agent_count": state.kernel.registry.count(), + "database": if db_ok { "connected" } else { "error" }, + "config_warnings": config_warnings, + })) +} + +// --------------------------------------------------------------------------- +// Prometheus metrics endpoint +// --------------------------------------------------------------------------- + +/// GET /api/metrics — Prometheus text-format metrics. +/// +/// Returns counters and gauges for monitoring OpenFang in production: +/// - `openfang_agents_active` — number of active agents +/// - `openfang_uptime_seconds` — seconds since daemon started +/// - `openfang_tokens_total` — total tokens consumed (per agent) +/// - `openfang_tool_calls_total` — total tool calls (per agent) +/// - `openfang_panics_total` — supervisor panic count +/// - `openfang_restarts_total` — supervisor restart count +pub async fn prometheus_metrics(State(state): State>) -> impl IntoResponse { + let mut out = String::with_capacity(2048); + + // Uptime + let uptime = state.started_at.elapsed().as_secs(); + out.push_str("# HELP openfang_uptime_seconds Time since daemon started.\n"); + out.push_str("# TYPE openfang_uptime_seconds gauge\n"); + out.push_str(&format!("openfang_uptime_seconds {uptime}\n\n")); + + // Active agents + let agents = state.kernel.registry.list(); + let active = agents + .iter() + .filter(|a| matches!(a.state, openfang_types::agent::AgentState::Running)) + .count(); + out.push_str("# HELP openfang_agents_active Number of active agents.\n"); + out.push_str("# TYPE openfang_agents_active gauge\n"); + out.push_str(&format!("openfang_agents_active {active}\n")); + out.push_str("# HELP openfang_agents_total Total number of registered agents.\n"); + out.push_str("# TYPE openfang_agents_total gauge\n"); + out.push_str(&format!("openfang_agents_total {}\n\n", agents.len())); + + // Per-agent token and tool usage + out.push_str("# HELP openfang_tokens_total Total tokens consumed (rolling hourly window).\n"); + out.push_str("# TYPE openfang_tokens_total gauge\n"); + out.push_str("# HELP openfang_tool_calls_total Total tool calls (rolling hourly window).\n"); + out.push_str("# TYPE openfang_tool_calls_total gauge\n"); + for agent in &agents { + let name = &agent.name; + let provider = &agent.manifest.model.provider; + let model = &agent.manifest.model.model; + if let Some((tokens, tools)) = state.kernel.scheduler.get_usage(agent.id) { + out.push_str(&format!( + "openfang_tokens_total{{agent=\"{name}\",provider=\"{provider}\",model=\"{model}\"}} {tokens}\n" + )); + out.push_str(&format!( + "openfang_tool_calls_total{{agent=\"{name}\"}} {tools}\n" + )); + } + } + out.push('\n'); + + // Supervisor health + let health = state.kernel.supervisor.health(); + out.push_str("# HELP openfang_panics_total Total supervisor panics since start.\n"); + out.push_str("# TYPE openfang_panics_total counter\n"); + out.push_str(&format!("openfang_panics_total {}\n", health.panic_count)); + out.push_str("# HELP openfang_restarts_total Total supervisor restarts since start.\n"); + out.push_str("# TYPE openfang_restarts_total counter\n"); + out.push_str(&format!( + "openfang_restarts_total {}\n\n", + health.restart_count + )); + + // Version info + out.push_str("# HELP openfang_info OpenFang version and build info.\n"); + out.push_str("# TYPE openfang_info gauge\n"); + out.push_str(&format!( + "openfang_info{{version=\"{}\"}} 1\n", + env!("CARGO_PKG_VERSION") + )); + + ( + StatusCode::OK, + [( + axum::http::header::CONTENT_TYPE, + "text/plain; version=0.0.4; charset=utf-8", + )], + out, + ) +} + +// --------------------------------------------------------------------------- +// Skills endpoints +// --------------------------------------------------------------------------- + +/// GET /api/skills — List installed skills. +pub async fn list_skills(State(state): State>) -> impl IntoResponse { + let skills_dir = state.kernel.config.home_dir.join("skills"); + let mut registry = openfang_skills::registry::SkillRegistry::new(skills_dir); + let _ = registry.load_all(); + + let skills: Vec = registry + .list() + .iter() + .map(|s| { + let source = match &s.manifest.source { + Some(openfang_skills::SkillSource::ClawHub { slug, version }) => { + serde_json::json!({"type": "clawhub", "slug": slug, "version": version}) + } + Some(openfang_skills::SkillSource::OpenClaw) => { + serde_json::json!({"type": "openclaw"}) + } + Some(openfang_skills::SkillSource::Bundled) => { + serde_json::json!({"type": "bundled"}) + } + Some(openfang_skills::SkillSource::Native) | None => { + serde_json::json!({"type": "local"}) + } + }; + serde_json::json!({ + "name": s.manifest.skill.name, + "description": s.manifest.skill.description, + "version": s.manifest.skill.version, + "author": s.manifest.skill.author, + "runtime": format!("{:?}", s.manifest.runtime.runtime_type), + "tools_count": s.manifest.tools.provided.len(), + "tags": s.manifest.skill.tags, + "enabled": s.enabled, + "source": source, + "has_prompt_context": s.manifest.prompt_context.is_some(), + }) + }) + .collect(); + + Json(serde_json::json!({ "skills": skills, "total": skills.len() })) +} + +/// POST /api/skills/install — Install a skill from FangHub (GitHub). +pub async fn install_skill( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + let skills_dir = state.kernel.config.home_dir.join("skills"); + let config = openfang_skills::marketplace::MarketplaceConfig::default(); + let client = openfang_skills::marketplace::MarketplaceClient::new(config); + + match client.install(&req.name, &skills_dir).await { + Ok(version) => { + // Hot-reload so agents see the new skill immediately + state.kernel.reload_skills(); + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "installed", + "name": req.name, + "version": version, + })), + ) + } + Err(e) => { + tracing::warn!("Skill install failed: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Install failed: {e}")})), + ) + } + } +} + +/// POST /api/skills/uninstall — Uninstall a skill. +pub async fn uninstall_skill( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + let skills_dir = state.kernel.config.home_dir.join("skills"); + let mut registry = openfang_skills::registry::SkillRegistry::new(skills_dir); + let _ = registry.load_all(); + + match registry.remove(&req.name) { + Ok(()) => { + // Hot-reload so agents stop seeing the removed skill + state.kernel.reload_skills(); + ( + StatusCode::OK, + Json(serde_json::json!({"status": "uninstalled", "name": req.name})), + ) + } + Err(e) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } +} + +/// GET /api/marketplace/search — Search the FangHub marketplace. +pub async fn marketplace_search( + Query(params): Query>, +) -> impl IntoResponse { + let query = params.get("q").cloned().unwrap_or_default(); + if query.is_empty() { + return Json(serde_json::json!({"results": [], "total": 0})); + } + + let config = openfang_skills::marketplace::MarketplaceConfig::default(); + let client = openfang_skills::marketplace::MarketplaceClient::new(config); + + match client.search(&query).await { + Ok(results) => { + let items: Vec = results + .iter() + .map(|r| { + serde_json::json!({ + "name": r.name, + "description": r.description, + "stars": r.stars, + "url": r.url, + }) + }) + .collect(); + Json(serde_json::json!({"results": items, "total": items.len()})) + } + Err(e) => { + tracing::warn!("Marketplace search failed: {e}"); + Json(serde_json::json!({"results": [], "total": 0, "error": format!("{e}")})) + } + } +} + +// --------------------------------------------------------------------------- +// ClawHub (OpenClaw ecosystem) endpoints +// --------------------------------------------------------------------------- + +/// GET /api/clawhub/search — Search ClawHub skills using vector/semantic search. +/// +/// Query parameters: +/// - `q` — search query (required) +/// - `limit` — max results (default: 20, max: 50) +pub async fn clawhub_search( + State(state): State>, + Query(params): Query>, +) -> impl IntoResponse { + let query = params.get("q").cloned().unwrap_or_default(); + if query.is_empty() { + return ( + StatusCode::OK, + Json(serde_json::json!({"items": [], "next_cursor": null})), + ); + } + + let limit: u32 = params + .get("limit") + .and_then(|v| v.parse().ok()) + .unwrap_or(20); + + // Check cache (120s TTL) + let cache_key = format!("search:{}:{}", query, limit); + if let Some(entry) = state.clawhub_cache.get(&cache_key) { + if entry.0.elapsed().as_secs() < 120 { + return (StatusCode::OK, Json(entry.1.clone())); + } + } + + let cache_dir = state.kernel.config.home_dir.join(".cache").join("clawhub"); + let client = openfang_skills::clawhub::ClawHubClient::new(cache_dir); + + match client.search(&query, limit).await { + Ok(results) => { + let items: Vec = results + .results + .iter() + .map(|e| { + serde_json::json!({ + "slug": e.slug, + "name": e.display_name, + "description": e.summary, + "version": e.version, + "score": e.score, + "updated_at": e.updated_at, + }) + }) + .collect(); + let resp = serde_json::json!({ + "items": items, + "next_cursor": null, + }); + state + .clawhub_cache + .insert(cache_key, (Instant::now(), resp.clone())); + (StatusCode::OK, Json(resp)) + } + Err(e) => { + let msg = format!("{e}"); + tracing::warn!("ClawHub search failed: {msg}"); + // Propagate 429 status instead of masking as 200 + let status = if msg.contains("429") || msg.contains("rate limit") { + StatusCode::TOO_MANY_REQUESTS + } else { + StatusCode::OK + }; + ( + status, + Json(serde_json::json!({"items": [], "next_cursor": null, "error": msg})), + ) + } + } +} + +/// GET /api/clawhub/browse — Browse ClawHub skills by sort order. +/// +/// Query parameters: +/// - `sort` — sort order: "trending", "downloads", "stars", "updated", "rating" (default: "trending") +/// - `limit` — max results (default: 20, max: 50) +/// - `cursor` — pagination cursor from previous response +pub async fn clawhub_browse( + State(state): State>, + Query(params): Query>, +) -> impl IntoResponse { + let sort = match params.get("sort").map(|s| s.as_str()) { + Some("downloads") => openfang_skills::clawhub::ClawHubSort::Downloads, + Some("stars") => openfang_skills::clawhub::ClawHubSort::Stars, + Some("updated") => openfang_skills::clawhub::ClawHubSort::Updated, + Some("rating") => openfang_skills::clawhub::ClawHubSort::Rating, + _ => openfang_skills::clawhub::ClawHubSort::Trending, + }; + + let limit: u32 = params + .get("limit") + .and_then(|v| v.parse().ok()) + .unwrap_or(20); + + let cursor = params.get("cursor").map(|s| s.as_str()); + + // Check cache (120s TTL) + let cache_key = format!("browse:{:?}:{}:{}", sort, limit, cursor.unwrap_or("")); + if let Some(entry) = state.clawhub_cache.get(&cache_key) { + if entry.0.elapsed().as_secs() < 120 { + return (StatusCode::OK, Json(entry.1.clone())); + } + } + + let cache_dir = state.kernel.config.home_dir.join(".cache").join("clawhub"); + let client = openfang_skills::clawhub::ClawHubClient::new(cache_dir); + + match client.browse(sort, limit, cursor).await { + Ok(results) => { + let items: Vec = results + .items + .iter() + .map(clawhub_browse_entry_to_json) + .collect(); + let resp = serde_json::json!({ + "items": items, + "next_cursor": results.next_cursor, + }); + state + .clawhub_cache + .insert(cache_key, (Instant::now(), resp.clone())); + (StatusCode::OK, Json(resp)) + } + Err(e) => { + let msg = format!("{e}"); + tracing::warn!("ClawHub browse failed: {msg}"); + let status = if msg.contains("429") || msg.contains("rate limit") { + StatusCode::TOO_MANY_REQUESTS + } else { + StatusCode::OK + }; + ( + status, + Json(serde_json::json!({"items": [], "next_cursor": null, "error": msg})), + ) + } + } +} + +/// GET /api/clawhub/skill/{slug} — Get detailed info about a ClawHub skill. +pub async fn clawhub_skill_detail( + State(state): State>, + Path(slug): Path, +) -> impl IntoResponse { + let cache_dir = state.kernel.config.home_dir.join(".cache").join("clawhub"); + let client = openfang_skills::clawhub::ClawHubClient::new(cache_dir); + + let skills_dir = state.kernel.config.home_dir.join("skills"); + let is_installed = client.is_installed(&slug, &skills_dir); + + match client.get_skill(&slug).await { + Ok(detail) => { + let version = detail + .latest_version + .as_ref() + .map(|v| v.version.as_str()) + .unwrap_or(""); + let author = detail + .owner + .as_ref() + .map(|o| o.handle.as_str()) + .unwrap_or(""); + let author_name = detail + .owner + .as_ref() + .map(|o| o.display_name.as_str()) + .unwrap_or(""); + let author_image = detail + .owner + .as_ref() + .map(|o| o.image.as_str()) + .unwrap_or(""); + + ( + StatusCode::OK, + Json(serde_json::json!({ + "slug": detail.skill.slug, + "name": detail.skill.display_name, + "description": detail.skill.summary, + "version": version, + "downloads": detail.skill.stats.downloads, + "stars": detail.skill.stats.stars, + "author": author, + "author_name": author_name, + "author_image": author_image, + "tags": detail.skill.tags, + "updated_at": detail.skill.updated_at, + "created_at": detail.skill.created_at, + "installed": is_installed, + })), + ) + } + Err(e) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } +} + +/// GET /api/clawhub/skill/{slug}/code — Fetch the source code (SKILL.md) of a ClawHub skill. +pub async fn clawhub_skill_code( + State(state): State>, + Path(slug): Path, +) -> impl IntoResponse { + let cache_dir = state.kernel.config.home_dir.join(".cache").join("clawhub"); + let client = openfang_skills::clawhub::ClawHubClient::new(cache_dir); + + // Try to fetch SKILL.md first, then fallback to package.json + let mut code = String::new(); + let mut filename = String::new(); + + if let Ok(content) = client.get_file(&slug, "SKILL.md").await { + code = content; + filename = "SKILL.md".to_string(); + } else if let Ok(content) = client.get_file(&slug, "package.json").await { + code = content; + filename = "package.json".to_string(); + } else if let Ok(content) = client.get_file(&slug, "skill.toml").await { + code = content; + filename = "skill.toml".to_string(); + } + + if code.is_empty() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "No source code found for this skill"})), + ); + } + + ( + StatusCode::OK, + Json(serde_json::json!({ + "slug": slug, + "filename": filename, + "code": code, + })), + ) +} + +/// POST /api/clawhub/install — Install a skill from ClawHub. +/// +/// Runs the full security pipeline: SHA256 verification, format detection, +/// manifest security scan, prompt injection scan, and binary dependency check. +pub async fn clawhub_install( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + let skills_dir = state.kernel.config.home_dir.join("skills"); + let cache_dir = state.kernel.config.home_dir.join(".cache").join("clawhub"); + let client = openfang_skills::clawhub::ClawHubClient::new(cache_dir); + + // Check if already installed + if client.is_installed(&req.slug, &skills_dir) { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": format!("Skill '{}' is already installed", req.slug), + "status": "already_installed", + })), + ); + } + + match client.install(&req.slug, &skills_dir).await { + Ok(result) => { + let warnings: Vec = result + .warnings + .iter() + .map(|w| { + serde_json::json!({ + "severity": format!("{:?}", w.severity), + "message": w.message, + }) + }) + .collect(); + + let translations: Vec = result + .tool_translations + .iter() + .map(|(from, to)| serde_json::json!({"from": from, "to": to})) + .collect(); + + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "installed", + "name": result.skill_name, + "version": result.version, + "slug": result.slug, + "is_prompt_only": result.is_prompt_only, + "warnings": warnings, + "tool_translations": translations, + })), + ) + } + Err(e) => { + let msg = format!("{e}"); + let status = if msg.contains("SecurityBlocked") { + StatusCode::FORBIDDEN + } else if msg.contains("429") || msg.contains("rate limit") { + StatusCode::TOO_MANY_REQUESTS + } else if msg.contains("Network error") + || msg.contains("returned 4") + || msg.contains("returned 5") + { + StatusCode::BAD_GATEWAY + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + tracing::warn!("ClawHub install failed: {msg}"); + (status, Json(serde_json::json!({"error": msg}))) + } + } +} + +/// Convert a browse entry (nested stats/tags) to a flat JSON object for the frontend. +fn clawhub_browse_entry_to_json( + entry: &openfang_skills::clawhub::ClawHubBrowseEntry, +) -> serde_json::Value { + let version = openfang_skills::clawhub::ClawHubClient::entry_version(entry); + serde_json::json!({ + "slug": entry.slug, + "name": entry.display_name, + "description": entry.summary, + "version": version, + "downloads": entry.stats.downloads, + "stars": entry.stats.stars, + "updated_at": entry.updated_at, + }) +} + +// --------------------------------------------------------------------------- +// Hands endpoints +// --------------------------------------------------------------------------- + +/// Detect the server platform for install command selection. +fn server_platform() -> &'static str { + if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "windows") { + "windows" + } else { + "linux" + } +} + +/// GET /api/hands — List all hand definitions (marketplace). +pub async fn list_hands(State(state): State>) -> impl IntoResponse { + let defs = state.kernel.hand_registry.list_definitions(); + let hands: Vec = defs + .iter() + .map(|d| { + let reqs = state + .kernel + .hand_registry + .check_requirements(&d.id) + .unwrap_or_default(); + let all_satisfied = reqs.iter().all(|(_, ok)| *ok); + serde_json::json!({ + "id": d.id, + "name": d.name, + "description": d.description, + "category": d.category, + "icon": d.icon, + "tools": d.tools, + "requirements_met": all_satisfied, + "requirements": reqs.iter().map(|(r, ok)| serde_json::json!({ + "key": r.key, + "label": r.label, + "satisfied": ok, + })).collect::>(), + "dashboard_metrics": d.dashboard.metrics.len(), + "has_settings": !d.settings.is_empty(), + "settings_count": d.settings.len(), + }) + }) + .collect(); + + Json(serde_json::json!({ "hands": hands, "total": hands.len() })) +} + +/// GET /api/hands/active — List active hand instances. +pub async fn list_active_hands(State(state): State>) -> impl IntoResponse { + let instances = state.kernel.hand_registry.list_instances(); + let items: Vec = instances + .iter() + .map(|i| { + serde_json::json!({ + "instance_id": i.instance_id, + "hand_id": i.hand_id, + "status": format!("{}", i.status), + "agent_id": i.agent_id.map(|a| a.to_string()), + "agent_name": i.agent_name, + "activated_at": i.activated_at.to_rfc3339(), + "updated_at": i.updated_at.to_rfc3339(), + }) + }) + .collect(); + + Json(serde_json::json!({ "instances": items, "total": items.len() })) +} + +/// GET /api/hands/{hand_id} — Get a single hand definition with requirements check. +pub async fn get_hand( + State(state): State>, + Path(hand_id): Path, +) -> impl IntoResponse { + match state.kernel.hand_registry.get_definition(&hand_id) { + Some(def) => { + let reqs = state + .kernel + .hand_registry + .check_requirements(&hand_id) + .unwrap_or_default(); + let all_satisfied = reqs.iter().all(|(_, ok)| *ok); + let settings_status = state + .kernel + .hand_registry + .check_settings_availability(&hand_id) + .unwrap_or_default(); + ( + StatusCode::OK, + Json(serde_json::json!({ + "id": def.id, + "name": def.name, + "description": def.description, + "category": def.category, + "icon": def.icon, + "tools": def.tools, + "requirements_met": all_satisfied, + "requirements": reqs.iter().map(|(r, ok)| { + let mut req_json = serde_json::json!({ + "key": r.key, + "label": r.label, + "type": format!("{:?}", r.requirement_type), + "check_value": r.check_value, + "satisfied": ok, + }); + if let Some(ref desc) = r.description { + req_json["description"] = serde_json::json!(desc); + } + if let Some(ref install) = r.install { + req_json["install"] = serde_json::to_value(install).unwrap_or_default(); + } + req_json + }).collect::>(), + "server_platform": server_platform(), + "agent": { + "name": def.agent.name, + "description": def.agent.description, + "provider": if def.agent.provider == "default" { + &state.kernel.config.default_model.provider + } else { &def.agent.provider }, + "model": if def.agent.model == "default" { + &state.kernel.config.default_model.model + } else { &def.agent.model }, + }, + "dashboard": def.dashboard.metrics.iter().map(|m| serde_json::json!({ + "label": m.label, + "memory_key": m.memory_key, + "format": m.format, + })).collect::>(), + "settings": settings_status, + })), + ) + } + None => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("Hand not found: {hand_id}")})), + ), + } +} + +/// POST /api/hands/{hand_id}/check-deps — Re-check dependency status for a hand. +pub async fn check_hand_deps( + State(state): State>, + Path(hand_id): Path, +) -> impl IntoResponse { + match state.kernel.hand_registry.get_definition(&hand_id) { + Some(def) => { + let reqs = state + .kernel + .hand_registry + .check_requirements(&hand_id) + .unwrap_or_default(); + let all_satisfied = reqs.iter().all(|(_, ok)| *ok); + ( + StatusCode::OK, + Json(serde_json::json!({ + "hand_id": def.id, + "requirements_met": all_satisfied, + "server_platform": server_platform(), + "requirements": reqs.iter().map(|(r, ok)| { + let mut req_json = serde_json::json!({ + "key": r.key, + "label": r.label, + "type": format!("{:?}", r.requirement_type), + "check_value": r.check_value, + "satisfied": ok, + }); + if let Some(ref desc) = r.description { + req_json["description"] = serde_json::json!(desc); + } + if let Some(ref install) = r.install { + req_json["install"] = serde_json::to_value(install).unwrap_or_default(); + } + req_json + }).collect::>(), + })), + ) + } + None => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("Hand not found: {hand_id}")})), + ), + } +} + +/// POST /api/hands/{hand_id}/install-deps — Auto-install missing dependencies for a hand. +pub async fn install_hand_deps( + State(state): State>, + Path(hand_id): Path, +) -> impl IntoResponse { + let def = match state.kernel.hand_registry.get_definition(&hand_id) { + Some(d) => d.clone(), + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("Hand not found: {hand_id}")})), + ); + } + }; + + let reqs = state + .kernel + .hand_registry + .check_requirements(&hand_id) + .unwrap_or_default(); + + let platform = server_platform(); + let mut results = Vec::new(); + + for (req, already_satisfied) in &reqs { + if *already_satisfied { + results.push(serde_json::json!({ + "key": req.key, + "status": "already_installed", + "message": format!("{} is already available", req.label), + })); + continue; + } + + let install = match &req.install { + Some(i) => i, + None => { + results.push(serde_json::json!({ + "key": req.key, + "status": "skipped", + "message": "No install instructions available", + })); + continue; + } + }; + + // Pick the best install command for this platform + let cmd = match platform { + "windows" => install.windows.as_deref().or(install.pip.as_deref()), + "macos" => install.macos.as_deref().or(install.pip.as_deref()), + _ => install + .linux_apt + .as_deref() + .or(install.linux_dnf.as_deref()) + .or(install.linux_pacman.as_deref()) + .or(install.pip.as_deref()), + }; + + let cmd = match cmd { + Some(c) => c, + None => { + results.push(serde_json::json!({ + "key": req.key, + "status": "no_command", + "message": format!("No install command for platform: {platform}"), + })); + continue; + } + }; + + // Execute the install command + let (shell, flag) = if cfg!(windows) { + ("cmd", "/C") + } else { + ("sh", "-c") + }; + + // For winget on Windows, add --accept flags to avoid interactive prompts + let final_cmd = if cfg!(windows) && cmd.starts_with("winget ") { + format!("{cmd} --accept-source-agreements --accept-package-agreements") + } else { + cmd.to_string() + }; + + tracing::info!(hand = %hand_id, dep = %req.key, cmd = %final_cmd, "Auto-installing dependency"); + + let output = match tokio::time::timeout( + std::time::Duration::from_secs(300), + tokio::process::Command::new(shell) + .arg(flag) + .arg(&final_cmd) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .stdin(std::process::Stdio::null()) + .output(), + ) + .await + { + Ok(Ok(out)) => out, + Ok(Err(e)) => { + results.push(serde_json::json!({ + "key": req.key, + "status": "error", + "command": final_cmd, + "message": format!("Failed to execute: {e}"), + })); + continue; + } + Err(_) => { + results.push(serde_json::json!({ + "key": req.key, + "status": "timeout", + "command": final_cmd, + "message": "Installation timed out after 5 minutes", + })); + continue; + } + }; + + let exit_code = output.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if exit_code == 0 { + results.push(serde_json::json!({ + "key": req.key, + "status": "installed", + "command": final_cmd, + "message": format!("{} installed successfully", req.label), + })); + } else { + // On Windows, winget may return non-zero even on success (e.g., already installed) + let combined = format!("{stdout}{stderr}"); + let likely_ok = combined.contains("already installed") + || combined.contains("No applicable update") + || combined.contains("No available upgrade"); + results.push(serde_json::json!({ + "key": req.key, + "status": if likely_ok { "installed" } else { "error" }, + "command": final_cmd, + "exit_code": exit_code, + "message": if likely_ok { + format!("{} is already installed", req.label) + } else { + let msg = stderr.chars().take(500).collect::(); + format!("Install failed (exit {}): {}", exit_code, msg.trim()) + }, + })); + } + } + + // On Windows, refresh PATH to pick up newly installed binaries from winget/pip + #[cfg(windows)] + { + let home = std::env::var("USERPROFILE").unwrap_or_default(); + if !home.is_empty() { + let winget_pkgs = + std::path::Path::new(&home).join("AppData\\Local\\Microsoft\\WinGet\\Packages"); + if winget_pkgs.is_dir() { + let mut extra_paths = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&winget_pkgs) { + for entry in entries.flatten() { + let pkg_dir = entry.path(); + // Look for bin/ subdirectory (ffmpeg style) + if let Ok(sub_entries) = std::fs::read_dir(&pkg_dir) { + for sub in sub_entries.flatten() { + let bin_dir = sub.path().join("bin"); + if bin_dir.is_dir() { + extra_paths.push(bin_dir.to_string_lossy().to_string()); + } + } + } + // Direct exe in package dir (yt-dlp style) + if std::fs::read_dir(&pkg_dir) + .map(|rd| { + rd.flatten().any(|e| { + e.path().extension().map(|x| x == "exe").unwrap_or(false) + }) + }) + .unwrap_or(false) + { + extra_paths.push(pkg_dir.to_string_lossy().to_string()); + } + } + } + // Also add pip Scripts dir + let pip_scripts = + std::path::Path::new(&home).join("AppData\\Local\\Programs\\Python"); + if pip_scripts.is_dir() { + if let Ok(entries) = std::fs::read_dir(&pip_scripts) { + for entry in entries.flatten() { + let scripts = entry.path().join("Scripts"); + if scripts.is_dir() { + extra_paths.push(scripts.to_string_lossy().to_string()); + } + } + } + } + if !extra_paths.is_empty() { + let current_path = std::env::var("PATH").unwrap_or_default(); + let new_path = format!("{};{}", extra_paths.join(";"), current_path); + std::env::set_var("PATH", &new_path); + tracing::info!( + added = extra_paths.len(), + "Refreshed PATH with winget/pip directories" + ); + } + } + } + } + + // Re-check requirements after installation + let reqs_after = state + .kernel + .hand_registry + .check_requirements(&hand_id) + .unwrap_or_default(); + let all_satisfied = reqs_after.iter().all(|(_, ok)| *ok); + + ( + StatusCode::OK, + Json(serde_json::json!({ + "hand_id": def.id, + "results": results, + "requirements_met": all_satisfied, + "requirements": reqs_after.iter().map(|(r, ok)| { + serde_json::json!({ + "key": r.key, + "label": r.label, + "satisfied": ok, + }) + }).collect::>(), + })), + ) +} + +/// POST /api/hands/install — Install a hand from TOML content. +pub async fn install_hand( + State(state): State>, + Json(body): Json, +) -> impl IntoResponse { + let toml_content = body["toml_content"].as_str().unwrap_or(""); + let skill_content = body["skill_content"].as_str().unwrap_or(""); + + if toml_content.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing toml_content field"})), + ); + } + + match state + .kernel + .hand_registry + .install_from_content(toml_content, skill_content) + { + Ok(def) => ( + StatusCode::OK, + Json(serde_json::json!({ + "id": def.id, + "name": def.name, + "description": def.description, + "category": format!("{:?}", def.category), + })), + ), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } +} + +/// POST /api/hands/{hand_id}/activate — Activate a hand (spawns agent). +pub async fn activate_hand( + State(state): State>, + Path(hand_id): Path, + body: Option>, +) -> impl IntoResponse { + let config = body.map(|b| b.0.config).unwrap_or_default(); + + match state.kernel.activate_hand(&hand_id, config) { + Ok(instance) => { + // If the hand agent has a non-reactive schedule (autonomous hands), + // start its background loop so it begins running immediately. + if let Some(agent_id) = instance.agent_id { + let entry = state + .kernel + .registry + .list() + .into_iter() + .find(|e| e.id == agent_id); + if let Some(entry) = entry { + if !matches!( + entry.manifest.schedule, + openfang_types::agent::ScheduleMode::Reactive + ) { + state.kernel.start_background_for_agent( + agent_id, + &entry.name, + &entry.manifest.schedule, + ); + } + } + } + ( + StatusCode::OK, + Json(serde_json::json!({ + "instance_id": instance.instance_id, + "hand_id": instance.hand_id, + "status": format!("{}", instance.status), + "agent_id": instance.agent_id.map(|a| a.to_string()), + "agent_name": instance.agent_name, + "activated_at": instance.activated_at.to_rfc3339(), + })), + ) + } + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } +} + +/// POST /api/hands/instances/{id}/pause — Pause a hand instance. +pub async fn pause_hand( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + match state.kernel.pause_hand(id) { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "paused", "instance_id": id})), + ), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } +} + +/// POST /api/hands/instances/{id}/resume — Resume a paused hand instance. +pub async fn resume_hand( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + match state.kernel.resume_hand(id) { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "resumed", "instance_id": id})), + ), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } +} + +/// DELETE /api/hands/instances/{id} — Deactivate a hand (kills agent). +pub async fn deactivate_hand( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + match state.kernel.deactivate_hand(id) { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "deactivated", "instance_id": id})), + ), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } +} + +/// GET /api/hands/{hand_id}/settings — Get settings schema and current values for a hand. +pub async fn get_hand_settings( + State(state): State>, + Path(hand_id): Path, +) -> impl IntoResponse { + let settings_status = match state + .kernel + .hand_registry + .check_settings_availability(&hand_id) + { + Ok(s) => s, + Err(_) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("Hand not found: {hand_id}")})), + ); + } + }; + + // Find active instance config values (if any) + let instance_config: std::collections::HashMap = state + .kernel + .hand_registry + .list_instances() + .iter() + .find(|i| i.hand_id == hand_id) + .map(|i| i.config.clone()) + .unwrap_or_default(); + + ( + StatusCode::OK, + Json(serde_json::json!({ + "hand_id": hand_id, + "settings": settings_status, + "current_values": instance_config, + })), + ) +} + +/// PUT /api/hands/{hand_id}/settings — Update settings for a hand instance. +pub async fn update_hand_settings( + State(state): State>, + Path(hand_id): Path, + Json(config): Json>, +) -> impl IntoResponse { + // Find active instance for this hand + let instance_id = state + .kernel + .hand_registry + .list_instances() + .iter() + .find(|i| i.hand_id == hand_id) + .map(|i| i.instance_id); + + match instance_id { + Some(id) => match state.kernel.hand_registry.update_config(id, config.clone()) { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "ok", + "hand_id": hand_id, + "instance_id": id, + "config": config, + })), + ), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": format!("{e}")})), + ), + }, + None => ( + StatusCode::NOT_FOUND, + Json( + serde_json::json!({"error": format!("No active instance for hand: {hand_id}. Activate the hand first.")}), + ), + ), + } +} + +/// GET /api/hands/instances/{id}/stats — Get dashboard stats for a hand instance. +pub async fn hand_stats( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let instance = match state.kernel.hand_registry.get_instance(id) { + Some(i) => i, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Instance not found"})), + ); + } + }; + + let def = match state.kernel.hand_registry.get_definition(&instance.hand_id) { + Some(d) => d, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Hand definition not found"})), + ); + } + }; + + let agent_id = match instance.agent_id { + Some(aid) => aid, + None => { + return ( + StatusCode::OK, + Json(serde_json::json!({ + "instance_id": id, + "hand_id": instance.hand_id, + "metrics": {}, + })), + ); + } + }; + + // Read dashboard metrics from agent's structured memory + let mut metrics = serde_json::Map::new(); + for metric in &def.dashboard.metrics { + let value = state + .kernel + .memory + .structured_get(agent_id, &metric.memory_key) + .ok() + .flatten() + .unwrap_or(serde_json::Value::Null); + metrics.insert( + metric.label.clone(), + serde_json::json!({ + "value": value, + "format": metric.format, + }), + ); + } + + ( + StatusCode::OK, + Json(serde_json::json!({ + "instance_id": id, + "hand_id": instance.hand_id, + "status": format!("{}", instance.status), + "agent_id": agent_id.to_string(), + "metrics": metrics, + })), + ) +} + +/// GET /api/hands/instances/{id}/browser — Get live browser state for a hand instance. +pub async fn hand_instance_browser( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + // 1. Look up instance + let instance = match state.kernel.hand_registry.get_instance(id) { + Some(i) => i, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Instance not found"})), + ); + } + }; + + // 2. Get agent_id + let agent_id = match instance.agent_id { + Some(aid) => aid, + None => { + return (StatusCode::OK, Json(serde_json::json!({"active": false}))); + } + }; + + let agent_id_str = agent_id.to_string(); + + // 3. Check if a browser session exists (without creating one) + if !state.kernel.browser_ctx.has_session(&agent_id_str) { + return (StatusCode::OK, Json(serde_json::json!({"active": false}))); + } + + // 4. Send ReadPage command to get page info + let mut url = String::new(); + let mut title = String::new(); + let mut content = String::new(); + + match state + .kernel + .browser_ctx + .send_command( + &agent_id_str, + openfang_runtime::browser::BrowserCommand::ReadPage, + ) + .await + { + Ok(resp) if resp.success => { + if let Some(data) = &resp.data { + url = data["url"].as_str().unwrap_or("").to_string(); + title = data["title"].as_str().unwrap_or("").to_string(); + content = data["content"].as_str().unwrap_or("").to_string(); + // Truncate content to avoid huge payloads (UTF-8 safe) + if content.len() > 2000 { + content = format!( + "{}... (truncated)", + openfang_types::truncate_str(&content, 2000) + ); + } + } + } + Ok(_) => {} // Non-success: leave defaults + Err(_) => {} // Error: leave defaults + } + + // 5. Send Screenshot command to get visual state + let mut screenshot_base64 = String::new(); + + match state + .kernel + .browser_ctx + .send_command( + &agent_id_str, + openfang_runtime::browser::BrowserCommand::Screenshot, + ) + .await + { + Ok(resp) if resp.success => { + if let Some(data) = &resp.data { + screenshot_base64 = data["image_base64"].as_str().unwrap_or("").to_string(); + } + } + Ok(_) => {} + Err(_) => {} + } + + // 6. Return combined state + ( + StatusCode::OK, + Json(serde_json::json!({ + "active": true, + "url": url, + "title": title, + "content": content, + "screenshot_base64": screenshot_base64, + })), + ) +} + +// --------------------------------------------------------------------------- +// MCP server endpoints +// --------------------------------------------------------------------------- + +/// GET /api/mcp/servers — List configured MCP servers and their tools. +pub async fn list_mcp_servers(State(state): State>) -> impl IntoResponse { + // Get configured servers from config + let config_servers: Vec = state + .kernel + .config + .mcp_servers + .iter() + .map(|s| { + let transport = match &s.transport { + openfang_types::config::McpTransportEntry::Stdio { command, args } => { + serde_json::json!({ + "type": "stdio", + "command": command, + "args": args, + }) + } + openfang_types::config::McpTransportEntry::Sse { url } => { + serde_json::json!({ + "type": "sse", + "url": url, + }) + } + }; + serde_json::json!({ + "name": s.name, + "transport": transport, + "timeout_secs": s.timeout_secs, + "env": s.env, + }) + }) + .collect(); + + // Get connected servers and their tools from the live MCP connections + let connections = state.kernel.mcp_connections.lock().await; + let connected: Vec = connections + .iter() + .map(|conn| { + let tools: Vec = conn + .tools() + .iter() + .map(|t| { + serde_json::json!({ + "name": t.name, + "description": t.description, + }) + }) + .collect(); + serde_json::json!({ + "name": conn.name(), + "tools_count": tools.len(), + "tools": tools, + "connected": true, + }) + }) + .collect(); + + Json(serde_json::json!({ + "configured": config_servers, + "connected": connected, + "total_configured": config_servers.len(), + "total_connected": connected.len(), + })) +} + +// --------------------------------------------------------------------------- +// Audit endpoints +// --------------------------------------------------------------------------- + +/// GET /api/audit/recent — Get recent audit log entries. +pub async fn audit_recent( + State(state): State>, + Query(params): Query>, +) -> impl IntoResponse { + let n: usize = params + .get("n") + .and_then(|v| v.parse().ok()) + .unwrap_or(50) + .min(1000); // Cap at 1000 + + let entries = state.kernel.audit_log.recent(n); + let tip = state.kernel.audit_log.tip_hash(); + + let items: Vec = entries + .iter() + .map(|e| { + serde_json::json!({ + "seq": e.seq, + "timestamp": e.timestamp, + "agent_id": e.agent_id, + "action": format!("{:?}", e.action), + "detail": e.detail, + "outcome": e.outcome, + "hash": e.hash, + }) + }) + .collect(); + + Json(serde_json::json!({ + "entries": items, + "total": state.kernel.audit_log.len(), + "tip_hash": tip, + })) +} + +/// GET /api/audit/verify — Verify the audit chain integrity. +pub async fn audit_verify(State(state): State>) -> impl IntoResponse { + let entry_count = state.kernel.audit_log.len(); + match state.kernel.audit_log.verify_integrity() { + Ok(()) => { + if entry_count == 0 { + // SECURITY: Warn that an empty audit log has no forensic value + Json(serde_json::json!({ + "valid": true, + "entries": 0, + "warning": "Audit log is empty — no events have been recorded yet", + "tip_hash": state.kernel.audit_log.tip_hash(), + })) + } else { + Json(serde_json::json!({ + "valid": true, + "entries": entry_count, + "tip_hash": state.kernel.audit_log.tip_hash(), + })) + } + } + Err(msg) => Json(serde_json::json!({ + "valid": false, + "error": msg, + "entries": entry_count, + })), + } +} + +/// GET /api/logs/stream — SSE endpoint for real-time audit log streaming. +/// +/// Streams new audit entries as Server-Sent Events. Accepts optional query +/// parameters for filtering: +/// - `level` — filter by classified level (info, warn, error) +/// - `filter` — text substring filter across action/detail/agent_id +/// - `token` — auth token (for EventSource clients that cannot set headers) +/// +/// A heartbeat ping is sent every 15 seconds to keep the connection alive. +/// The endpoint polls the audit log every second and sends only new entries +/// (tracked by sequence number). On first connect, existing entries are sent +/// as a backfill so the client has immediate context. +pub async fn logs_stream( + State(state): State>, + Query(params): Query>, +) -> axum::response::Response { + use axum::response::sse::{Event, KeepAlive, Sse}; + + let level_filter = params.get("level").cloned().unwrap_or_default(); + let text_filter = params + .get("filter") + .cloned() + .unwrap_or_default() + .to_lowercase(); + + let (tx, rx) = tokio::sync::mpsc::channel::< + Result, + >(256); + + tokio::spawn(async move { + let mut last_seq: u64 = 0; + let mut first_poll = true; + + loop { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + let entries = state.kernel.audit_log.recent(200); + + for entry in &entries { + // On first poll, send all existing entries as backfill. + // After that, only send entries newer than last_seq. + if !first_poll && entry.seq <= last_seq { + continue; + } + + let action_str = format!("{:?}", entry.action); + + // Apply level filter + if !level_filter.is_empty() { + let classified = classify_audit_level(&action_str); + if classified != level_filter { + continue; + } + } + + // Apply text filter + if !text_filter.is_empty() { + let haystack = format!("{} {} {}", action_str, entry.detail, entry.agent_id) + .to_lowercase(); + if !haystack.contains(&text_filter) { + continue; + } + } + + let json = serde_json::json!({ + "seq": entry.seq, + "timestamp": entry.timestamp, + "agent_id": entry.agent_id, + "action": action_str, + "detail": entry.detail, + "outcome": entry.outcome, + "hash": entry.hash, + }); + let data = serde_json::to_string(&json).unwrap_or_default(); + if tx.send(Ok(Event::default().data(data))).await.is_err() { + return; // Client disconnected + } + } + + // Update tracking state + if let Some(last) = entries.last() { + last_seq = last.seq; + } + first_poll = false; + } + }); + + let rx_stream = tokio_stream::wrappers::ReceiverStream::new(rx); + Sse::new(rx_stream) + .keep_alive( + KeepAlive::new() + .interval(std::time::Duration::from_secs(15)) + .text("ping"), + ) + .into_response() +} + +/// Classify an audit action string into a level (info, warn, error). +fn classify_audit_level(action: &str) -> &'static str { + let a = action.to_lowercase(); + if a.contains("error") || a.contains("fail") || a.contains("crash") || a.contains("denied") { + "error" + } else if a.contains("warn") || a.contains("block") || a.contains("kill") { + "warn" + } else { + "info" + } +} + +// --------------------------------------------------------------------------- +// Peer endpoints +// --------------------------------------------------------------------------- + +/// GET /api/peers — List known OFP peers. +pub async fn list_peers(State(state): State>) -> impl IntoResponse { + // Peers are tracked in the wire module's PeerRegistry. + // The kernel doesn't directly hold a PeerRegistry, so we return an empty list + // unless one is available. The API server can be extended to inject a registry. + if let Some(ref peer_registry) = state.peer_registry { + let peers: Vec = peer_registry + .all_peers() + .iter() + .map(|p| { + serde_json::json!({ + "node_id": p.node_id, + "node_name": p.node_name, + "address": p.address.to_string(), + "state": format!("{:?}", p.state), + "agents": p.agents.iter().map(|a| serde_json::json!({ + "id": a.id, + "name": a.name, + })).collect::>(), + "connected_at": p.connected_at.to_rfc3339(), + "protocol_version": p.protocol_version, + }) + }) + .collect(); + Json(serde_json::json!({"peers": peers, "total": peers.len()})) + } else { + Json(serde_json::json!({"peers": [], "total": 0})) + } +} + +/// GET /api/network/status — OFP network status summary. +pub async fn network_status(State(state): State>) -> impl IntoResponse { + let enabled = state.kernel.config.network_enabled + && !state.kernel.config.network.shared_secret.is_empty(); + + let (node_id, listen_address, connected_peers, total_peers) = + if let Some(ref peer_node) = state.kernel.peer_node { + let registry = peer_node.registry(); + ( + peer_node.node_id().to_string(), + peer_node.local_addr().to_string(), + registry.connected_count(), + registry.total_count(), + ) + } else { + (String::new(), String::new(), 0, 0) + }; + + Json(serde_json::json!({ + "enabled": enabled, + "node_id": node_id, + "listen_address": listen_address, + "connected_peers": connected_peers, + "total_peers": total_peers, + })) +} + +// --------------------------------------------------------------------------- +// Tools endpoint +// --------------------------------------------------------------------------- + +/// GET /api/tools — List all tool definitions (built-in + MCP). +pub async fn list_tools(State(state): State>) -> impl IntoResponse { + let mut tools: Vec = builtin_tool_definitions() + .iter() + .map(|t| { + serde_json::json!({ + "name": t.name, + "description": t.description, + "input_schema": t.input_schema, + }) + }) + .collect(); + + // Include MCP tools so they're visible in Settings -> Tools + if let Ok(mcp_tools) = state.kernel.mcp_tools.lock() { + for t in mcp_tools.iter() { + tools.push(serde_json::json!({ + "name": t.name, + "description": t.description, + "input_schema": t.input_schema, + "source": "mcp", + })); + } + } + + Json(serde_json::json!({"tools": tools, "total": tools.len()})) +} + +// --------------------------------------------------------------------------- +// Config endpoint +// --------------------------------------------------------------------------- + +/// GET /api/config — Get kernel configuration (secrets redacted). +pub async fn get_config(State(state): State>) -> impl IntoResponse { + // Return a redacted view of the kernel config + let config = &state.kernel.config; + Json(serde_json::json!({ + "home_dir": config.home_dir.to_string_lossy(), + "data_dir": config.data_dir.to_string_lossy(), + "api_key": if config.api_key.is_empty() { "not set" } else { "***" }, + "default_model": { + "provider": config.default_model.provider, + "model": config.default_model.model, + "api_key_env": config.default_model.api_key_env, + }, + "memory": { + "decay_rate": config.memory.decay_rate, + }, + })) +} + +// --------------------------------------------------------------------------- +// Usage endpoint +// --------------------------------------------------------------------------- + +/// GET /api/usage — Get per-agent usage statistics. +pub async fn usage_stats(State(state): State>) -> impl IntoResponse { + let agents: Vec = state + .kernel + .registry + .list() + .iter() + .map(|e| { + let (tokens, tool_calls) = state.kernel.scheduler.get_usage(e.id).unwrap_or((0, 0)); + serde_json::json!({ + "agent_id": e.id.to_string(), + "name": e.name, + "total_tokens": tokens, + "tool_calls": tool_calls, + }) + }) + .collect(); + + Json(serde_json::json!({"agents": agents})) +} + +// --------------------------------------------------------------------------- +// Usage summary endpoints +// --------------------------------------------------------------------------- + +/// GET /api/usage/summary — Get overall usage summary from UsageStore. +pub async fn usage_summary(State(state): State>) -> impl IntoResponse { + match state.kernel.memory.usage().query_summary(None) { + Ok(s) => Json(serde_json::json!({ + "total_input_tokens": s.total_input_tokens, + "total_output_tokens": s.total_output_tokens, + "total_cost_usd": s.total_cost_usd, + "call_count": s.call_count, + "total_tool_calls": s.total_tool_calls, + })), + Err(_) => Json(serde_json::json!({ + "total_input_tokens": 0, + "total_output_tokens": 0, + "total_cost_usd": 0.0, + "call_count": 0, + "total_tool_calls": 0, + })), + } +} + +/// GET /api/usage/by-model — Get usage grouped by model. +pub async fn usage_by_model(State(state): State>) -> impl IntoResponse { + match state.kernel.memory.usage().query_by_model() { + Ok(models) => { + let list: Vec = models + .iter() + .map(|m| { + serde_json::json!({ + "model": m.model, + "total_cost_usd": m.total_cost_usd, + "total_input_tokens": m.total_input_tokens, + "total_output_tokens": m.total_output_tokens, + "call_count": m.call_count, + }) + }) + .collect(); + Json(serde_json::json!({"models": list})) + } + Err(_) => Json(serde_json::json!({"models": []})), + } +} + +/// GET /api/usage/daily — Get daily usage breakdown for the last 7 days. +pub async fn usage_daily(State(state): State>) -> impl IntoResponse { + let days = state.kernel.memory.usage().query_daily_breakdown(7); + let today_cost = state.kernel.memory.usage().query_today_cost(); + let first_event = state.kernel.memory.usage().query_first_event_date(); + + let days_list = match days { + Ok(d) => d + .iter() + .map(|day| { + serde_json::json!({ + "date": day.date, + "cost_usd": day.cost_usd, + "tokens": day.tokens, + "calls": day.calls, + }) + }) + .collect::>(), + Err(_) => vec![], + }; + + Json(serde_json::json!({ + "days": days_list, + "today_cost_usd": today_cost.unwrap_or(0.0), + "first_event_date": first_event.unwrap_or(None), + })) +} + +// --------------------------------------------------------------------------- +// Budget endpoints +// --------------------------------------------------------------------------- + +/// GET /api/budget — Current budget status (limits, spend, % used). +pub async fn budget_status(State(state): State>) -> impl IntoResponse { + let status = state + .kernel + .metering + .budget_status(&state.kernel.config.budget); + Json(serde_json::to_value(&status).unwrap_or_default()) +} + +/// PUT /api/budget — Update global budget limits (in-memory only, not persisted to config.toml). +pub async fn update_budget( + State(state): State>, + Json(body): Json, +) -> impl IntoResponse { + // SAFETY: Budget config is updated in-place. Since KernelConfig is behind + // an Arc and we only have &self, we use ptr mutation (same pattern as OFP). + let config_ptr = &state.kernel.config as *const openfang_types::config::KernelConfig + as *mut openfang_types::config::KernelConfig; + + // Apply updates + unsafe { + if let Some(v) = body["max_hourly_usd"].as_f64() { + (*config_ptr).budget.max_hourly_usd = v; + } + if let Some(v) = body["max_daily_usd"].as_f64() { + (*config_ptr).budget.max_daily_usd = v; + } + if let Some(v) = body["max_monthly_usd"].as_f64() { + (*config_ptr).budget.max_monthly_usd = v; + } + if let Some(v) = body["alert_threshold"].as_f64() { + (*config_ptr).budget.alert_threshold = v.clamp(0.0, 1.0); + } + } + + let status = state + .kernel + .metering + .budget_status(&state.kernel.config.budget); + Json(serde_json::to_value(&status).unwrap_or_default()) +} + +/// GET /api/budget/agents/{id} — Per-agent budget/quota status. +pub async fn agent_budget_status( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ) + } + }; + + let entry = match state.kernel.registry.get(agent_id) { + Some(e) => e, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ) + } + }; + + let quota = &entry.manifest.resources; + let usage_store = openfang_memory::usage::UsageStore::new(state.kernel.memory.usage_conn()); + let hourly = usage_store.query_hourly(agent_id).unwrap_or(0.0); + let daily = usage_store.query_daily(agent_id).unwrap_or(0.0); + let monthly = usage_store.query_monthly(agent_id).unwrap_or(0.0); + + ( + StatusCode::OK, + Json(serde_json::json!({ + "agent_id": agent_id.to_string(), + "agent_name": entry.name, + "hourly": { + "spend": hourly, + "limit": quota.max_cost_per_hour_usd, + "pct": if quota.max_cost_per_hour_usd > 0.0 { hourly / quota.max_cost_per_hour_usd } else { 0.0 }, + }, + "daily": { + "spend": daily, + "limit": quota.max_cost_per_day_usd, + "pct": if quota.max_cost_per_day_usd > 0.0 { daily / quota.max_cost_per_day_usd } else { 0.0 }, + }, + "monthly": { + "spend": monthly, + "limit": quota.max_cost_per_month_usd, + "pct": if quota.max_cost_per_month_usd > 0.0 { monthly / quota.max_cost_per_month_usd } else { 0.0 }, + }, + })), + ) +} + +/// GET /api/budget/agents — Per-agent cost ranking (top spenders). +pub async fn agent_budget_ranking(State(state): State>) -> impl IntoResponse { + let usage_store = openfang_memory::usage::UsageStore::new(state.kernel.memory.usage_conn()); + let agents: Vec = state + .kernel + .registry + .list() + .iter() + .filter_map(|entry| { + let daily = usage_store.query_daily(entry.id).unwrap_or(0.0); + if daily > 0.0 { + Some(serde_json::json!({ + "agent_id": entry.id.to_string(), + "name": entry.name, + "daily_cost_usd": daily, + "hourly_limit": entry.manifest.resources.max_cost_per_hour_usd, + "daily_limit": entry.manifest.resources.max_cost_per_day_usd, + "monthly_limit": entry.manifest.resources.max_cost_per_month_usd, + })) + } else { + None + } + }) + .collect(); + + Json(serde_json::json!({"agents": agents, "total": agents.len()})) +} + +/// PUT /api/budget/agents/{id} — Update per-agent budget limits at runtime. +pub async fn update_agent_budget( + State(state): State>, + Path(id): Path, + Json(body): Json, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ) + } + }; + + let hourly = body["max_cost_per_hour_usd"].as_f64(); + let daily = body["max_cost_per_day_usd"].as_f64(); + let monthly = body["max_cost_per_month_usd"].as_f64(); + + if hourly.is_none() && daily.is_none() && monthly.is_none() { + return ( + StatusCode::BAD_REQUEST, + Json( + serde_json::json!({"error": "Provide at least one of: max_cost_per_hour_usd, max_cost_per_day_usd, max_cost_per_month_usd"}), + ), + ); + } + + match state + .kernel + .registry + .update_resources(agent_id, hourly, daily, monthly) + { + Ok(()) => { + // Persist updated entry + if let Some(entry) = state.kernel.registry.get(agent_id) { + let _ = state.kernel.memory.save_agent(&entry); + } + ( + StatusCode::OK, + Json(serde_json::json!({"status": "ok", "message": "Agent budget updated"})), + ) + } + Err(e) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } +} + +// --------------------------------------------------------------------------- +// Session listing endpoints +// --------------------------------------------------------------------------- + +/// GET /api/sessions — List all sessions with metadata. +pub async fn list_sessions(State(state): State>) -> impl IntoResponse { + match state.kernel.memory.list_sessions() { + Ok(sessions) => Json(serde_json::json!({"sessions": sessions})), + Err(_) => Json(serde_json::json!({"sessions": []})), + } +} + +/// DELETE /api/sessions/:id — Delete a session. +pub async fn delete_session( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let session_id = match id.parse::() { + Ok(u) => openfang_types::agent::SessionId(u), + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid session ID"})), + ); + } + }; + + match state.kernel.memory.delete_session(session_id) { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "deleted", "session_id": id})), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ), + } +} + +/// PUT /api/sessions/:id/label — Set a session label. +pub async fn set_session_label( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let session_id = match id.parse::() { + Ok(u) => openfang_types::agent::SessionId(u), + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid session ID"})), + ); + } + }; + + let label = req.get("label").and_then(|v| v.as_str()); + + // Validate label if present + if let Some(lbl) = label { + if let Err(e) = openfang_types::agent::SessionLabel::new(lbl) { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": e.to_string()})), + ); + } + } + + match state.kernel.memory.set_session_label(session_id, label) { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "updated", + "session_id": id, + "label": label, + })), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ), + } +} + +/// GET /api/sessions/by-label/:label — Find session by label (scoped to agent). +pub async fn find_session_by_label( + State(state): State>, + Path((agent_id_str, label)): Path<(String, String)>, +) -> impl IntoResponse { + let agent_id = match agent_id_str.parse::() { + Ok(u) => openfang_types::agent::AgentId(u), + Err(_) => { + // Try name lookup + match state.kernel.registry.find_by_name(&agent_id_str) { + Some(entry) => entry.id, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ); + } + } + } + }; + + match state.kernel.memory.find_session_by_label(agent_id, &label) { + Ok(Some(session)) => ( + StatusCode::OK, + Json(serde_json::json!({ + "session_id": session.id.0.to_string(), + "agent_id": session.agent_id.0.to_string(), + "label": session.label, + "message_count": session.messages.len(), + })), + ), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "No session found with that label"})), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ), + } +} + +// --------------------------------------------------------------------------- +// Trigger update endpoint +// --------------------------------------------------------------------------- + +/// PUT /api/triggers/:id — Update a trigger (enable/disable toggle). +pub async fn update_trigger( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let trigger_id = TriggerId(match id.parse() { + Ok(u) => u, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid trigger ID"})), + ); + } + }); + + if let Some(enabled) = req.get("enabled").and_then(|v| v.as_bool()) { + if state.kernel.set_trigger_enabled(trigger_id, enabled) { + ( + StatusCode::OK, + Json( + serde_json::json!({"status": "updated", "trigger_id": id, "enabled": enabled}), + ), + ) + } else { + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Trigger not found"})), + ) + } + } else { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing 'enabled' field"})), + ) + } +} + +// --------------------------------------------------------------------------- +// Agent update endpoint +// --------------------------------------------------------------------------- + +/// PUT /api/agents/:id — Update an agent (currently: re-set manifest fields). +pub async fn update_agent( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ); + } + }; + + if state.kernel.registry.get(agent_id).is_none() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ); + } + + // Parse the new manifest + let _manifest: AgentManifest = match toml::from_str(&req.manifest_toml) { + Ok(m) => m, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": format!("Invalid manifest: {e}")})), + ); + } + }; + + // Note: Full manifest update requires kill + respawn. For now, acknowledge receipt. + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "acknowledged", + "agent_id": id, + "note": "Full manifest update requires agent restart. Use DELETE + POST to apply.", + })), + ) +} + +// --------------------------------------------------------------------------- +// Migration endpoint +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Security dashboard endpoint +// --------------------------------------------------------------------------- + +/// GET /api/security — Security feature status for the dashboard. +pub async fn security_status(State(state): State>) -> impl IntoResponse { + let auth_mode = if state.kernel.config.api_key.is_empty() { + "localhost_only" + } else { + "bearer_token" + }; + + let audit_count = state.kernel.audit_log.len(); + + Json(serde_json::json!({ + "core_protections": { + "path_traversal": true, + "ssrf_protection": true, + "capability_system": true, + "privilege_escalation_prevention": true, + "subprocess_isolation": true, + "security_headers": true, + "wire_hmac_auth": true, + "request_id_tracking": true + }, + "configurable": { + "rate_limiter": { + "enabled": true, + "tokens_per_minute": 500, + "algorithm": "GCRA" + }, + "websocket_limits": { + "max_per_ip": 5, + "idle_timeout_secs": 1800, + "max_message_size": 65536, + "max_messages_per_minute": 10 + }, + "wasm_sandbox": { + "fuel_metering": true, + "epoch_interruption": true, + "default_timeout_secs": 30, + "default_fuel_limit": 1_000_000u64 + }, + "auth": { + "mode": auth_mode, + "api_key_set": !state.kernel.config.api_key.is_empty() + } + }, + "monitoring": { + "audit_trail": { + "enabled": true, + "algorithm": "SHA-256 Merkle Chain", + "entry_count": audit_count + }, + "taint_tracking": { + "enabled": true, + "tracked_labels": [ + "ExternalNetwork", + "UserInput", + "PII", + "Secret", + "UntrustedAgent" + ] + }, + "manifest_signing": { + "algorithm": "Ed25519", + "available": true + } + }, + "secret_zeroization": true, + "total_features": 15 + })) +} + +/// GET /api/migrate/detect — Auto-detect OpenClaw installation. +pub async fn migrate_detect() -> impl IntoResponse { + match openfang_migrate::openclaw::detect_openclaw_home() { + Some(path) => { + let scan = openfang_migrate::openclaw::scan_openclaw_workspace(&path); + ( + StatusCode::OK, + Json(serde_json::json!({ + "detected": true, + "path": path.display().to_string(), + "scan": scan, + })), + ) + } + None => ( + StatusCode::OK, + Json(serde_json::json!({ + "detected": false, + "path": null, + "scan": null, + })), + ), + } +} + +/// POST /api/migrate/scan — Scan a specific directory for OpenClaw workspace. +pub async fn migrate_scan(Json(req): Json) -> impl IntoResponse { + let path = std::path::PathBuf::from(&req.path); + if !path.exists() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Directory not found"})), + ); + } + let scan = openfang_migrate::openclaw::scan_openclaw_workspace(&path); + (StatusCode::OK, Json(serde_json::json!(scan))) +} + +/// POST /api/migrate — Run migration from another agent framework. +pub async fn run_migrate(Json(req): Json) -> impl IntoResponse { + let source = match req.source.as_str() { + "openclaw" => openfang_migrate::MigrateSource::OpenClaw, + "langchain" => openfang_migrate::MigrateSource::LangChain, + "autogpt" => openfang_migrate::MigrateSource::AutoGpt, + other => { + return ( + StatusCode::BAD_REQUEST, + Json( + serde_json::json!({"error": format!("Unknown source: {other}. Use 'openclaw', 'langchain', or 'autogpt'")}), + ), + ); + } + }; + + let options = openfang_migrate::MigrateOptions { + source, + source_dir: std::path::PathBuf::from(&req.source_dir), + target_dir: std::path::PathBuf::from(&req.target_dir), + dry_run: req.dry_run, + }; + + match openfang_migrate::run_migration(&options) { + Ok(report) => { + let imported: Vec = report + .imported + .iter() + .map(|i| { + serde_json::json!({ + "kind": format!("{}", i.kind), + "name": i.name, + "destination": i.destination, + }) + }) + .collect(); + + let skipped: Vec = report + .skipped + .iter() + .map(|s| { + serde_json::json!({ + "kind": format!("{}", s.kind), + "name": s.name, + "reason": s.reason, + }) + }) + .collect(); + + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "completed", + "dry_run": req.dry_run, + "imported": imported, + "imported_count": imported.len(), + "skipped": skipped, + "skipped_count": skipped.len(), + "warnings": report.warnings, + "report_markdown": report.to_markdown(), + })), + ) + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Migration failed: {e}")})), + ), + } +} + +// ── Model Catalog Endpoints ───────────────────────────────────────── + +/// GET /api/models — List all models in the catalog. +/// +/// Query parameters: +/// - `provider` — filter by provider (e.g. `?provider=anthropic`) +/// - `tier` — filter by tier (e.g. `?tier=smart`) +/// - `available` — only show models from configured providers (`?available=true`) +pub async fn list_models( + State(state): State>, + Query(params): Query>, +) -> impl IntoResponse { + let catalog = state + .kernel + .model_catalog + .read() + .unwrap_or_else(|e| e.into_inner()); + let provider_filter = params.get("provider").map(|s| s.to_lowercase()); + let tier_filter = params.get("tier").map(|s| s.to_lowercase()); + let available_only = params + .get("available") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + + let models: Vec = catalog + .list_models() + .iter() + .filter(|m| { + if let Some(ref p) = provider_filter { + if m.provider.to_lowercase() != *p { + return false; + } + } + if let Some(ref t) = tier_filter { + if m.tier.to_string() != *t { + return false; + } + } + if available_only { + let provider = catalog.get_provider(&m.provider); + if let Some(p) = provider { + if p.auth_status == openfang_types::model_catalog::AuthStatus::Missing { + return false; + } + } + } + true + }) + .map(|m| { + let available = catalog + .get_provider(&m.provider) + .map(|p| p.auth_status != openfang_types::model_catalog::AuthStatus::Missing) + .unwrap_or(false); + serde_json::json!({ + "id": m.id, + "display_name": m.display_name, + "provider": m.provider, + "tier": m.tier, + "context_window": m.context_window, + "max_output_tokens": m.max_output_tokens, + "input_cost_per_m": m.input_cost_per_m, + "output_cost_per_m": m.output_cost_per_m, + "supports_tools": m.supports_tools, + "supports_vision": m.supports_vision, + "supports_streaming": m.supports_streaming, + "available": available, + }) + }) + .collect(); + + let total = catalog.list_models().len(); + let available_count = catalog.available_models().len(); + + ( + StatusCode::OK, + Json(serde_json::json!({ + "models": models, + "total": total, + "available": available_count, + })), + ) +} + +/// GET /api/models/aliases — List all alias-to-model mappings. +pub async fn list_aliases(State(state): State>) -> impl IntoResponse { + let aliases = state + .kernel + .model_catalog + .read() + .unwrap_or_else(|e| e.into_inner()) + .list_aliases() + .clone(); + let entries: Vec = aliases + .iter() + .map(|(alias, model_id)| { + serde_json::json!({ + "alias": alias, + "model_id": model_id, + }) + }) + .collect(); + + ( + StatusCode::OK, + Json(serde_json::json!({ + "aliases": entries, + "total": entries.len(), + })), + ) +} + +/// GET /api/models/{id} — Get a single model by ID or alias. +pub async fn get_model( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let catalog = state + .kernel + .model_catalog + .read() + .unwrap_or_else(|e| e.into_inner()); + match catalog.find_model(&id) { + Some(m) => { + let available = catalog + .get_provider(&m.provider) + .map(|p| p.auth_status != openfang_types::model_catalog::AuthStatus::Missing) + .unwrap_or(false); + ( + StatusCode::OK, + Json(serde_json::json!({ + "id": m.id, + "display_name": m.display_name, + "provider": m.provider, + "tier": m.tier, + "context_window": m.context_window, + "max_output_tokens": m.max_output_tokens, + "input_cost_per_m": m.input_cost_per_m, + "output_cost_per_m": m.output_cost_per_m, + "supports_tools": m.supports_tools, + "supports_vision": m.supports_vision, + "supports_streaming": m.supports_streaming, + "aliases": m.aliases, + "available": available, + })), + ) + } + None => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("Model '{}' not found", id)})), + ), + } +} + +/// GET /api/providers — List all providers with auth status. +/// +/// For local providers (ollama, vllm, lmstudio), also probes reachability and +/// discovers available models via their health endpoints. +pub async fn list_providers(State(state): State>) -> impl IntoResponse { + let provider_list: Vec = { + let catalog = state + .kernel + .model_catalog + .read() + .unwrap_or_else(|e| e.into_inner()); + catalog.list_providers().to_vec() + }; + + let mut providers: Vec = Vec::with_capacity(provider_list.len()); + + for p in &provider_list { + let mut entry = serde_json::json!({ + "id": p.id, + "display_name": p.display_name, + "auth_status": p.auth_status, + "model_count": p.model_count, + "key_required": p.key_required, + "api_key_env": p.api_key_env, + "base_url": p.base_url, + }); + + // For local providers, add reachability info via health probe + if !p.key_required { + entry["is_local"] = serde_json::json!(true); + let probe = openfang_runtime::provider_health::probe_provider(&p.id, &p.base_url).await; + entry["reachable"] = serde_json::json!(probe.reachable); + entry["latency_ms"] = serde_json::json!(probe.latency_ms); + if !probe.discovered_models.is_empty() { + entry["discovered_models"] = serde_json::json!(probe.discovered_models); + // Merge discovered models into the catalog so agents can use them + if let Ok(mut catalog) = state.kernel.model_catalog.write() { + catalog.merge_discovered_models(&p.id, &probe.discovered_models); + } + } + if let Some(err) = &probe.error { + entry["error"] = serde_json::json!(err); + } + } + + providers.push(entry); + } + + let total = providers.len(); + ( + StatusCode::OK, + Json(serde_json::json!({ + "providers": providers, + "total": total, + })), + ) +} + +/// POST /api/models/custom — Add a custom model to the catalog. +/// +/// Persists to `~/.openfang/custom_models.json` and makes the model immediately +/// available for agent assignment. +pub async fn add_custom_model( + State(state): State>, + Json(body): Json, +) -> impl IntoResponse { + let id = body + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let provider = body + .get("provider") + .and_then(|v| v.as_str()) + .unwrap_or("openrouter") + .to_string(); + let context_window = body + .get("context_window") + .and_then(|v| v.as_u64()) + .unwrap_or(128_000); + let max_output = body + .get("max_output_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(8_192); + + if id.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing required field: id"})), + ); + } + + let display = body + .get("display_name") + .and_then(|v| v.as_str()) + .unwrap_or(&id) + .to_string(); + + let entry = openfang_types::model_catalog::ModelCatalogEntry { + id: id.clone(), + display_name: display, + provider: provider.clone(), + tier: openfang_types::model_catalog::ModelTier::Custom, + context_window, + max_output_tokens: max_output, + input_cost_per_m: body + .get("input_cost_per_m") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0), + output_cost_per_m: body + .get("output_cost_per_m") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0), + supports_tools: body + .get("supports_tools") + .and_then(|v| v.as_bool()) + .unwrap_or(true), + supports_vision: body + .get("supports_vision") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + supports_streaming: body + .get("supports_streaming") + .and_then(|v| v.as_bool()) + .unwrap_or(true), + aliases: vec![], + }; + + let mut catalog = state + .kernel + .model_catalog + .write() + .unwrap_or_else(|e| e.into_inner()); + + if !catalog.add_custom_model(entry) { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({"error": format!("Model '{}' already exists", id)})), + ); + } + + // Persist to disk + let custom_path = state.kernel.config.home_dir.join("custom_models.json"); + if let Err(e) = catalog.save_custom_models(&custom_path) { + tracing::warn!("Failed to persist custom models: {e}"); + } + + ( + StatusCode::CREATED, + Json(serde_json::json!({ + "id": id, + "provider": provider, + "status": "added" + })), + ) +} + +/// DELETE /api/models/custom/{id} — Remove a custom model. +pub async fn remove_custom_model( + State(state): State>, + axum::extract::Path(model_id): axum::extract::Path, +) -> impl IntoResponse { + let mut catalog = state + .kernel + .model_catalog + .write() + .unwrap_or_else(|e| e.into_inner()); + + if !catalog.remove_custom_model(&model_id) { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("Custom model '{}' not found", model_id)})), + ); + } + + let custom_path = state.kernel.config.home_dir.join("custom_models.json"); + if let Err(e) = catalog.save_custom_models(&custom_path) { + tracing::warn!("Failed to persist custom models: {e}"); + } + + ( + StatusCode::OK, + Json(serde_json::json!({"status": "removed"})), + ) +} + +// ── A2A (Agent-to-Agent) Protocol Endpoints ───────────────────────── + +/// GET /.well-known/agent.json — A2A Agent Card for the default agent. +pub async fn a2a_agent_card(State(state): State>) -> impl IntoResponse { + let agents = state.kernel.registry.list(); + let base_url = format!("http://{}", state.kernel.config.api_listen); + + if let Some(first) = agents.first() { + let card = openfang_runtime::a2a::build_agent_card(&first.manifest, &base_url); + ( + StatusCode::OK, + Json(serde_json::to_value(&card).unwrap_or_default()), + ) + } else { + let card = serde_json::json!({ + "name": "openfang", + "description": "OpenFang Agent OS — no agents spawned yet", + "url": format!("{base_url}/a2a"), + "version": "0.1.0", + "capabilities": { "streaming": true }, + "skills": [], + "defaultInputModes": ["text"], + "defaultOutputModes": ["text"], + }); + (StatusCode::OK, Json(card)) + } +} + +/// GET /a2a/agents — List all A2A agent cards. +pub async fn a2a_list_agents(State(state): State>) -> impl IntoResponse { + let agents = state.kernel.registry.list(); + let base_url = format!("http://{}", state.kernel.config.api_listen); + + let cards: Vec = agents + .iter() + .map(|entry| { + let card = openfang_runtime::a2a::build_agent_card(&entry.manifest, &base_url); + serde_json::to_value(&card).unwrap_or_default() + }) + .collect(); + + let total = cards.len(); + ( + StatusCode::OK, + Json(serde_json::json!({ + "agents": cards, + "total": total, + })), + ) +} + +/// POST /a2a/tasks/send — Submit a task to an agent via A2A. +pub async fn a2a_send_task( + State(state): State>, + Json(request): Json, +) -> impl IntoResponse { + // Extract message text from A2A format + let message_text = request["params"]["message"]["parts"] + .as_array() + .and_then(|parts| { + parts.iter().find_map(|p| { + if p["type"].as_str() == Some("text") { + p["text"].as_str().map(String::from) + } else { + None + } + }) + }) + .unwrap_or_else(|| "No message provided".to_string()); + + // Find target agent (use first available or specified) + let agents = state.kernel.registry.list(); + if agents.is_empty() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "No agents available"})), + ); + } + + let agent = &agents[0]; + let task_id = uuid::Uuid::new_v4().to_string(); + let session_id = request["params"]["sessionId"].as_str().map(String::from); + + // Create the task in the store as Working + let task = openfang_runtime::a2a::A2aTask { + id: task_id.clone(), + session_id: session_id.clone(), + status: openfang_runtime::a2a::A2aTaskStatus::Working, + messages: vec![openfang_runtime::a2a::A2aMessage { + role: "user".to_string(), + parts: vec![openfang_runtime::a2a::A2aPart::Text { + text: message_text.clone(), + }], + }], + artifacts: vec![], + }; + state.kernel.a2a_task_store.insert(task); + + // Send message to agent + match state.kernel.send_message(agent.id, &message_text).await { + Ok(result) => { + let response_msg = openfang_runtime::a2a::A2aMessage { + role: "agent".to_string(), + parts: vec![openfang_runtime::a2a::A2aPart::Text { + text: result.response, + }], + }; + state + .kernel + .a2a_task_store + .complete(&task_id, response_msg, vec![]); + match state.kernel.a2a_task_store.get(&task_id) { + Some(completed_task) => ( + StatusCode::OK, + Json(serde_json::to_value(&completed_task).unwrap_or_default()), + ), + None => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Task disappeared after completion"})), + ), + } + } + Err(e) => { + let error_msg = openfang_runtime::a2a::A2aMessage { + role: "agent".to_string(), + parts: vec![openfang_runtime::a2a::A2aPart::Text { + text: format!("Error: {e}"), + }], + }; + state.kernel.a2a_task_store.fail(&task_id, error_msg); + match state.kernel.a2a_task_store.get(&task_id) { + Some(failed_task) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::to_value(&failed_task).unwrap_or_default()), + ), + None => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Agent error: {e}")})), + ), + } + } + } +} + +/// GET /a2a/tasks/{id} — Get task status from the task store. +pub async fn a2a_get_task( + State(state): State>, + Path(task_id): Path, +) -> impl IntoResponse { + match state.kernel.a2a_task_store.get(&task_id) { + Some(task) => ( + StatusCode::OK, + Json(serde_json::to_value(&task).unwrap_or_default()), + ), + None => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("Task '{}' not found", task_id)})), + ), + } +} + +/// POST /a2a/tasks/{id}/cancel — Cancel a tracked task. +pub async fn a2a_cancel_task( + State(state): State>, + Path(task_id): Path, +) -> impl IntoResponse { + if state.kernel.a2a_task_store.cancel(&task_id) { + match state.kernel.a2a_task_store.get(&task_id) { + Some(task) => ( + StatusCode::OK, + Json(serde_json::to_value(&task).unwrap_or_default()), + ), + None => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Task disappeared after cancellation"})), + ), + } + } else { + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("Task '{}' not found", task_id)})), + ) + } +} + +// ── A2A Management Endpoints (outbound) ───────────────────────────────── + +/// GET /api/a2a/agents — List discovered external A2A agents. +pub async fn a2a_list_external_agents(State(state): State>) -> impl IntoResponse { + let agents = state + .kernel + .a2a_external_agents + .lock() + .unwrap_or_else(|e| e.into_inner()); + let items: Vec = agents + .iter() + .map(|(url, card)| { + serde_json::json!({ + "name": card.name, + "url": url, + "description": card.description, + "skills": card.skills, + "version": card.version, + }) + }) + .collect(); + Json(serde_json::json!({"agents": items, "total": items.len()})) +} + +/// POST /api/a2a/discover — Discover a new external A2A agent by URL. +pub async fn a2a_discover_external( + State(state): State>, + Json(body): Json, +) -> impl IntoResponse { + let url = match body["url"].as_str() { + Some(u) => u.to_string(), + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing 'url' field"})), + ) + } + }; + + let client = openfang_runtime::a2a::A2aClient::new(); + match client.discover(&url).await { + Ok(card) => { + let card_json = serde_json::to_value(&card).unwrap_or_default(); + // Store in kernel's external agents list + { + let mut agents = state + .kernel + .a2a_external_agents + .lock() + .unwrap_or_else(|e| e.into_inner()); + // Update or add + if let Some(existing) = agents.iter_mut().find(|(u, _)| u == &url) { + existing.1 = card; + } else { + agents.push((url.clone(), card)); + } + } + ( + StatusCode::OK, + Json(serde_json::json!({ + "url": url, + "agent": card_json, + })), + ) + } + Err(e) => ( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({"error": e})), + ), + } +} + +/// POST /api/a2a/send — Send a task to an external A2A agent. +pub async fn a2a_send_external( + State(_state): State>, + Json(body): Json, +) -> impl IntoResponse { + let url = match body["url"].as_str() { + Some(u) => u.to_string(), + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing 'url' field"})), + ) + } + }; + let message = match body["message"].as_str() { + Some(m) => m.to_string(), + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing 'message' field"})), + ) + } + }; + let session_id = body["session_id"].as_str(); + + let client = openfang_runtime::a2a::A2aClient::new(); + match client.send_task(&url, &message, session_id).await { + Ok(task) => ( + StatusCode::OK, + Json(serde_json::to_value(&task).unwrap_or_default()), + ), + Err(e) => ( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({"error": e})), + ), + } +} + +/// GET /api/a2a/tasks/{id}/status — Get task status from an external A2A agent. +pub async fn a2a_external_task_status( + State(_state): State>, + Path(task_id): Path, + axum::extract::Query(params): axum::extract::Query>, +) -> impl IntoResponse { + let url = match params.get("url") { + Some(u) => u.clone(), + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing 'url' query parameter"})), + ) + } + }; + + let client = openfang_runtime::a2a::A2aClient::new(); + match client.get_task(&url, &task_id).await { + Ok(task) => ( + StatusCode::OK, + Json(serde_json::to_value(&task).unwrap_or_default()), + ), + Err(e) => ( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({"error": e})), + ), + } +} + +// ── MCP HTTP Endpoint ─────────────────────────────────────────────────── + +/// POST /mcp — Handle MCP JSON-RPC requests over HTTP. +/// +/// Exposes the same MCP protocol normally served via stdio, allowing +/// external MCP clients to connect over HTTP instead. +pub async fn mcp_http( + State(state): State>, + Json(request): Json, +) -> impl IntoResponse { + // Gather all available tools (builtin + skills + MCP) + let mut tools = builtin_tool_definitions(); + { + let registry = state + .kernel + .skill_registry + .read() + .unwrap_or_else(|e| e.into_inner()); + for skill_tool in registry.all_tool_definitions() { + tools.push(openfang_types::tool::ToolDefinition { + name: skill_tool.name.clone(), + description: skill_tool.description.clone(), + input_schema: skill_tool.input_schema.clone(), + }); + } + } + if let Ok(mcp_tools) = state.kernel.mcp_tools.lock() { + tools.extend(mcp_tools.iter().cloned()); + } + + // Check if this is a tools/call that needs real execution + let method = request["method"].as_str().unwrap_or(""); + if method == "tools/call" { + let tool_name = request["params"]["name"].as_str().unwrap_or(""); + let arguments = request["params"] + .get("arguments") + .cloned() + .unwrap_or(serde_json::json!({})); + + // Verify the tool exists + if !tools.iter().any(|t| t.name == tool_name) { + return Json(serde_json::json!({ + "jsonrpc": "2.0", + "id": request.get("id").cloned(), + "error": {"code": -32602, "message": format!("Unknown tool: {tool_name}")} + })); + } + + // Snapshot skill registry before async call (RwLockReadGuard is !Send) + let skill_snapshot = state + .kernel + .skill_registry + .read() + .unwrap_or_else(|e| e.into_inner()) + .snapshot(); + + // Execute the tool via the kernel's tool runner + let kernel_handle: Arc = + state.kernel.clone() as Arc; + let result = openfang_runtime::tool_runner::execute_tool( + "mcp-http", + tool_name, + &arguments, + Some(&kernel_handle), + None, + None, + Some(&skill_snapshot), + Some(&state.kernel.mcp_connections), + Some(&state.kernel.web_ctx), + Some(&state.kernel.browser_ctx), + None, + None, + Some(&state.kernel.media_engine), + None, // exec_policy + if state.kernel.config.tts.enabled { + Some(&state.kernel.tts_engine) + } else { + None + }, + if state.kernel.config.docker.enabled { + Some(&state.kernel.config.docker) + } else { + None + }, + Some(&*state.kernel.process_manager), + ) + .await; + + return Json(serde_json::json!({ + "jsonrpc": "2.0", + "id": request.get("id").cloned(), + "result": { + "content": [{"type": "text", "text": result.content}], + "isError": result.is_error, + } + })); + } + + // For non-tools/call methods (initialize, tools/list, etc.), delegate to the handler + let response = openfang_runtime::mcp_server::handle_mcp_request(&request, &tools).await; + Json(response) +} + +// ── Multi-Session Endpoints ───────────────────────────────────────────── + +/// GET /api/agents/{id}/sessions — List all sessions for an agent. +pub async fn list_agent_sessions( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ) + } + }; + match state.kernel.list_agent_sessions(agent_id) { + Ok(sessions) => ( + StatusCode::OK, + Json(serde_json::json!({"sessions": sessions})), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } +} + +/// POST /api/agents/{id}/sessions — Create a new session for an agent. +pub async fn create_agent_session( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ) + } + }; + let label = req.get("label").and_then(|v| v.as_str()); + match state.kernel.create_agent_session(agent_id, label) { + Ok(session) => (StatusCode::OK, Json(session)), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } +} + +/// POST /api/agents/{id}/sessions/{session_id}/switch — Switch to an existing session. +pub async fn switch_agent_session( + State(state): State>, + Path((id, session_id_str)): Path<(String, String)>, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ) + } + }; + let session_id = match session_id_str.parse::() { + Ok(uuid) => openfang_types::agent::SessionId(uuid), + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid session ID"})), + ) + } + }; + match state.kernel.switch_agent_session(agent_id, session_id) { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "ok", "message": "Session switched"})), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } +} + +// ── Extended Chat Command API Endpoints ───────────────────────────────── + +/// POST /api/agents/{id}/session/reset — Reset an agent's session. +pub async fn reset_session( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ) + } + }; + match state.kernel.reset_session(agent_id) { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "ok", "message": "Session reset"})), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } +} + +/// DELETE /api/agents/{id}/history — Clear ALL conversation history for an agent. +pub async fn clear_agent_history( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ) + } + }; + match state.kernel.clear_agent_history(agent_id) { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "ok", "message": "All history cleared"})), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } +} + +/// POST /api/agents/{id}/session/compact — Trigger LLM session compaction. +pub async fn compact_session( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ) + } + }; + match state.kernel.compact_agent_session(agent_id).await { + Ok(msg) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "ok", "message": msg})), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } +} + +/// POST /api/agents/{id}/stop — Cancel an agent's current LLM run. +pub async fn stop_agent( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ) + } + }; + match state.kernel.stop_agent_run(agent_id) { + Ok(true) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "ok", "message": "Run cancelled"})), + ), + Ok(false) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "ok", "message": "No active run"})), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } +} + +/// PUT /api/agents/{id}/model — Switch an agent's model. +pub async fn set_model( + State(state): State>, + Path(id): Path, + Json(body): Json, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ) + } + }; + let model = match body["model"].as_str() { + Some(m) if !m.is_empty() => m, + _ => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing 'model' field"})), + ) + } + }; + match state.kernel.set_agent_model(agent_id, model) { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "ok", "model": model})), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } +} + +/// GET /api/agents/{id}/tools — Get an agent's tool allowlist/blocklist. +pub async fn get_agent_tools( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ) + } + }; + let entry = match state.kernel.registry.get(agent_id) { + Some(e) => e, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ) + } + }; + ( + StatusCode::OK, + Json(serde_json::json!({ + "tool_allowlist": entry.manifest.tool_allowlist, + "tool_blocklist": entry.manifest.tool_blocklist, + })), + ) +} + +/// PUT /api/agents/{id}/tools — Update an agent's tool allowlist/blocklist. +pub async fn set_agent_tools( + State(state): State>, + Path(id): Path, + Json(body): Json, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ) + } + }; + let allowlist = body + .get("tool_allowlist") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect::>() + }); + let blocklist = body + .get("tool_blocklist") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect::>() + }); + + if allowlist.is_none() && blocklist.is_none() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Provide 'tool_allowlist' and/or 'tool_blocklist'"})), + ); + } + + match state + .kernel + .set_agent_tool_filters(agent_id, allowlist, blocklist) + { + Ok(()) => (StatusCode::OK, Json(serde_json::json!({"status": "ok"}))), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } +} + +// ── Per-Agent Skill & MCP Endpoints ──────────────────────────────────── + +/// GET /api/agents/{id}/skills — Get an agent's skill assignment info. +pub async fn get_agent_skills( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ) + } + }; + let entry = match state.kernel.registry.get(agent_id) { + Some(e) => e, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ) + } + }; + let available = state + .kernel + .skill_registry + .read() + .unwrap_or_else(|e| e.into_inner()) + .skill_names(); + let mode = if entry.manifest.skills.is_empty() { + "all" + } else { + "allowlist" + }; + ( + StatusCode::OK, + Json(serde_json::json!({ + "assigned": entry.manifest.skills, + "available": available, + "mode": mode, + })), + ) +} + +/// PUT /api/agents/{id}/skills — Update an agent's skill allowlist. +pub async fn set_agent_skills( + State(state): State>, + Path(id): Path, + Json(body): Json, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ) + } + }; + let skills: Vec = body["skills"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + match state.kernel.set_agent_skills(agent_id, skills.clone()) { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "ok", "skills": skills})), + ), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } +} + +/// GET /api/agents/{id}/mcp_servers — Get an agent's MCP server assignment info. +pub async fn get_agent_mcp_servers( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ) + } + }; + let entry = match state.kernel.registry.get(agent_id) { + Some(e) => e, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ) + } + }; + // Collect known MCP server names from connected tools + let mut available: Vec = Vec::new(); + if let Ok(mcp_tools) = state.kernel.mcp_tools.lock() { + let mut seen = std::collections::HashSet::new(); + for tool in mcp_tools.iter() { + if let Some(server) = openfang_runtime::mcp::extract_mcp_server(&tool.name) { + if seen.insert(server.to_string()) { + available.push(server.to_string()); + } + } + } + } + let mode = if entry.manifest.mcp_servers.is_empty() { + "all" + } else { + "allowlist" + }; + ( + StatusCode::OK, + Json(serde_json::json!({ + "assigned": entry.manifest.mcp_servers, + "available": available, + "mode": mode, + })), + ) +} + +/// PUT /api/agents/{id}/mcp_servers — Update an agent's MCP server allowlist. +pub async fn set_agent_mcp_servers( + State(state): State>, + Path(id): Path, + Json(body): Json, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ) + } + }; + let servers: Vec = body["mcp_servers"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + match state + .kernel + .set_agent_mcp_servers(agent_id, servers.clone()) + { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "ok", "mcp_servers": servers})), + ), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } +} + +// ── Provider Key Management Endpoints ────────────────────────────────── + +/// POST /api/providers/{name}/key — Save an API key for a provider. +/// +/// SECURITY: Writes to `~/.openfang/secrets.env`, sets env var in process, +/// and refreshes auth detection. Key is zeroized after use. +pub async fn set_provider_key( + State(state): State>, + Path(name): Path, + Json(body): Json, +) -> impl IntoResponse { + // Validate provider name against known list + { + let catalog = state + .kernel + .model_catalog + .read() + .unwrap_or_else(|e| e.into_inner()); + if catalog.get_provider(&name).is_none() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("Unknown provider '{}'", name)})), + ); + } + } + + let key = match body["key"].as_str() { + Some(k) if !k.trim().is_empty() => k.trim().to_string(), + _ => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing or empty 'key' field"})), + ); + } + }; + + let env_var = { + let catalog = state + .kernel + .model_catalog + .read() + .unwrap_or_else(|e| e.into_inner()); + catalog + .get_provider(&name) + .map(|p| p.api_key_env.clone()) + .unwrap_or_default() + }; + + if env_var.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Provider does not require an API key"})), + ); + } + + // Write to secrets.env file + let secrets_path = state.kernel.config.home_dir.join("secrets.env"); + if let Err(e) = write_secret_env(&secrets_path, &env_var, &key) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Failed to write secrets.env: {e}")})), + ); + } + + // Set env var in current process so detect_auth picks it up + std::env::set_var(&env_var, &key); + + // Refresh auth detection + state + .kernel + .model_catalog + .write() + .unwrap_or_else(|e| e.into_inner()) + .detect_auth(); + + ( + StatusCode::OK, + Json(serde_json::json!({"status": "saved", "provider": name})), + ) +} + +/// DELETE /api/providers/{name}/key — Remove an API key for a provider. +pub async fn delete_provider_key( + State(state): State>, + Path(name): Path, +) -> impl IntoResponse { + let env_var = { + let catalog = state + .kernel + .model_catalog + .read() + .unwrap_or_else(|e| e.into_inner()); + match catalog.get_provider(&name) { + Some(p) => p.api_key_env.clone(), + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("Unknown provider '{}'", name)})), + ); + } + } + }; + + if env_var.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Provider does not require an API key"})), + ); + } + + // Remove from secrets.env + let secrets_path = state.kernel.config.home_dir.join("secrets.env"); + if let Err(e) = remove_secret_env(&secrets_path, &env_var) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Failed to update secrets.env: {e}")})), + ); + } + + // Remove from process environment + std::env::remove_var(&env_var); + + // Refresh auth detection + state + .kernel + .model_catalog + .write() + .unwrap_or_else(|e| e.into_inner()) + .detect_auth(); + + ( + StatusCode::OK, + Json(serde_json::json!({"status": "removed", "provider": name})), + ) +} + +/// POST /api/providers/{name}/test — Test a provider's connectivity. +pub async fn test_provider( + State(state): State>, + Path(name): Path, +) -> impl IntoResponse { + let (env_var, base_url, key_required, default_model) = { + let catalog = state + .kernel + .model_catalog + .read() + .unwrap_or_else(|e| e.into_inner()); + match catalog.get_provider(&name) { + Some(p) => { + // Find a default model for this provider to use in the test request + let model_id = catalog + .default_model_for_provider(&name) + .unwrap_or_default(); + ( + p.api_key_env.clone(), + p.base_url.clone(), + p.key_required, + model_id, + ) + } + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("Unknown provider '{}'", name)})), + ); + } + } + }; + + let api_key = std::env::var(&env_var).ok(); + // Only require API key for providers that need one (skip local providers like ollama/vllm/lmstudio) + if key_required && api_key.is_none() && !env_var.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Provider API key not configured"})), + ); + } + + // Attempt a lightweight connectivity test + let start = std::time::Instant::now(); + let driver_config = openfang_runtime::llm_driver::DriverConfig { + provider: name.clone(), + api_key, + base_url: if base_url.is_empty() { + None + } else { + Some(base_url) + }, + }; + + match openfang_runtime::drivers::create_driver(&driver_config) { + Ok(driver) => { + // Send a minimal completion request to test connectivity + let test_req = openfang_runtime::llm_driver::CompletionRequest { + model: default_model.clone(), + messages: vec![openfang_types::message::Message::user("Hi")], + tools: vec![], + max_tokens: 1, + temperature: 0.0, + system: None, + thinking: None, + }; + match driver.complete(test_req).await { + Ok(_) => { + let latency_ms = start.elapsed().as_millis(); + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "ok", + "provider": name, + "latency_ms": latency_ms, + })), + ) + } + Err(e) => ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "error", + "provider": name, + "error": format!("{e}"), + })), + ), + } + } + Err(e) => ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "error", + "provider": name, + "error": format!("Failed to create driver: {e}"), + })), + ), + } +} + +/// PUT /api/providers/{name}/url — Set a custom base URL for a provider. +pub async fn set_provider_url( + State(state): State>, + Path(name): Path, + Json(body): Json, +) -> impl IntoResponse { + // Validate provider exists + let provider_exists = { + let catalog = state + .kernel + .model_catalog + .read() + .unwrap_or_else(|e| e.into_inner()); + catalog.get_provider(&name).is_some() + }; + if !provider_exists { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("Unknown provider '{}'", name)})), + ); + } + + let base_url = match body["base_url"].as_str() { + Some(u) if !u.trim().is_empty() => u.trim().to_string(), + _ => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing or empty 'base_url' field"})), + ); + } + }; + + // Validate URL scheme + if !base_url.starts_with("http://") && !base_url.starts_with("https://") { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "base_url must start with http:// or https://"})), + ); + } + + // Update catalog in memory + { + let mut catalog = state + .kernel + .model_catalog + .write() + .unwrap_or_else(|e| e.into_inner()); + catalog.set_provider_url(&name, &base_url); + } + + // Persist to config.toml [provider_urls] section + let config_path = state.kernel.config.home_dir.join("config.toml"); + if let Err(e) = upsert_provider_url(&config_path, &name, &base_url) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Failed to save config: {e}")})), + ); + } + + // Probe reachability at the new URL + let probe = openfang_runtime::provider_health::probe_provider(&name, &base_url).await; + + // Merge discovered models into catalog + if !probe.discovered_models.is_empty() { + if let Ok(mut catalog) = state.kernel.model_catalog.write() { + catalog.merge_discovered_models(&name, &probe.discovered_models); + } + } + + let mut resp = serde_json::json!({ + "status": "saved", + "provider": name, + "base_url": base_url, + "reachable": probe.reachable, + "latency_ms": probe.latency_ms, + }); + if !probe.discovered_models.is_empty() { + resp["discovered_models"] = serde_json::json!(probe.discovered_models); + } + + (StatusCode::OK, Json(resp)) +} + +/// Upsert a provider URL in the `[provider_urls]` section of config.toml. +fn upsert_provider_url( + config_path: &std::path::Path, + provider: &str, + url: &str, +) -> Result<(), Box> { + let content = if config_path.exists() { + std::fs::read_to_string(config_path)? + } else { + String::new() + }; + + let mut doc: toml::Value = if content.trim().is_empty() { + toml::Value::Table(toml::map::Map::new()) + } else { + toml::from_str(&content)? + }; + + let root = doc.as_table_mut().ok_or("Config is not a TOML table")?; + + if !root.contains_key("provider_urls") { + root.insert( + "provider_urls".to_string(), + toml::Value::Table(toml::map::Map::new()), + ); + } + let urls_table = root + .get_mut("provider_urls") + .and_then(|v| v.as_table_mut()) + .ok_or("provider_urls is not a table")?; + + urls_table.insert(provider.to_string(), toml::Value::String(url.to_string())); + + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent)?; + } + + std::fs::write(config_path, toml::to_string_pretty(&doc)?)?; + Ok(()) +} + +/// POST /api/skills/create — Create a local prompt-only skill. +pub async fn create_skill( + State(state): State>, + Json(body): Json, +) -> impl IntoResponse { + let name = match body["name"].as_str() { + Some(n) if !n.trim().is_empty() => n.trim().to_string(), + _ => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing or empty 'name' field"})), + ); + } + }; + + // Validate name (alphanumeric + hyphens only) + if !name + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + { + return ( + StatusCode::BAD_REQUEST, + Json( + serde_json::json!({"error": "Skill name must contain only letters, numbers, hyphens, and underscores"}), + ), + ); + } + + let description = body["description"].as_str().unwrap_or("").to_string(); + let runtime = body["runtime"].as_str().unwrap_or("prompt_only"); + let prompt_context = body["prompt_context"].as_str().unwrap_or("").to_string(); + + // Only allow prompt_only skills from the web UI for safety + if runtime != "prompt_only" { + return ( + StatusCode::BAD_REQUEST, + Json( + serde_json::json!({"error": "Only prompt_only skills can be created from the web UI"}), + ), + ); + } + + // Write skill.toml to ~/.openfang/skills/{name}/ + let skill_dir = state.kernel.config.home_dir.join("skills").join(&name); + if skill_dir.exists() { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({"error": format!("Skill '{}' already exists", name)})), + ); + } + + if let Err(e) = std::fs::create_dir_all(&skill_dir) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Failed to create skill directory: {e}")})), + ); + } + + let toml_content = format!( + "[skill]\nname = \"{}\"\ndescription = \"{}\"\nruntime = \"prompt_only\"\n\n[prompt]\ncontext = \"\"\"\n{}\n\"\"\"\n", + name, + description.replace('"', "\\\""), + prompt_context + ); + + let toml_path = skill_dir.join("skill.toml"); + if let Err(e) = std::fs::write(&toml_path, &toml_content) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Failed to write skill.toml: {e}")})), + ); + } + + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "created", + "name": name, + "note": "Restart the daemon to load the new skill, or it will be available on next boot." + })), + ) +} + +// ── Helper functions for secrets.env management ──────────────────────── + +/// Write or update a key in the secrets.env file. +/// File format: one `KEY=value` per line. Existing keys are overwritten. +fn write_secret_env(path: &std::path::Path, key: &str, value: &str) -> Result<(), std::io::Error> { + let mut lines: Vec = if path.exists() { + std::fs::read_to_string(path)? + .lines() + .map(|l| l.to_string()) + .collect() + } else { + Vec::new() + }; + + // Remove existing line for this key + lines.retain(|l| !l.starts_with(&format!("{key}="))); + + // Add new line + lines.push(format!("{key}={value}")); + + // Ensure parent directory exists + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + std::fs::write(path, lines.join("\n") + "\n")?; + + // SECURITY: Restrict file permissions on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + } + + Ok(()) +} + +/// Remove a key from the secrets.env file. +fn remove_secret_env(path: &std::path::Path, key: &str) -> Result<(), std::io::Error> { + if !path.exists() { + return Ok(()); + } + + let lines: Vec = std::fs::read_to_string(path)? + .lines() + .filter(|l| !l.starts_with(&format!("{key}="))) + .map(|l| l.to_string()) + .collect(); + + std::fs::write(path, lines.join("\n") + "\n")?; + + Ok(()) +} + +// ── Config.toml channel management helpers ────────────────────────── + +/// Upsert a `[channels.]` section in config.toml with the given non-secret fields. +fn upsert_channel_config( + config_path: &std::path::Path, + channel_name: &str, + fields: &HashMap, +) -> Result<(), Box> { + let content = if config_path.exists() { + std::fs::read_to_string(config_path)? + } else { + String::new() + }; + + let mut doc: toml::Value = if content.trim().is_empty() { + toml::Value::Table(toml::map::Map::new()) + } else { + toml::from_str(&content)? + }; + + let root = doc.as_table_mut().ok_or("Config is not a TOML table")?; + + // Ensure [channels] table exists + if !root.contains_key("channels") { + root.insert( + "channels".to_string(), + toml::Value::Table(toml::map::Map::new()), + ); + } + let channels_table = root + .get_mut("channels") + .and_then(|v| v.as_table_mut()) + .ok_or("channels is not a table")?; + + // Build channel sub-table with correct TOML types + let mut ch_table = toml::map::Map::new(); + for (k, (v, ft)) in fields { + let toml_val = match ft { + FieldType::Number => { + if let Ok(n) = v.parse::() { + toml::Value::Integer(n) + } else { + toml::Value::String(v.clone()) + } + } + _ => toml::Value::String(v.clone()), + }; + ch_table.insert(k.clone(), toml_val); + } + channels_table.insert(channel_name.to_string(), toml::Value::Table(ch_table)); + + // Ensure parent directory exists + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent)?; + } + + std::fs::write(config_path, toml::to_string_pretty(&doc)?)?; + Ok(()) +} + +/// Remove a `[channels.]` section from config.toml. +fn remove_channel_config( + config_path: &std::path::Path, + channel_name: &str, +) -> Result<(), Box> { + if !config_path.exists() { + return Ok(()); + } + + let content = std::fs::read_to_string(config_path)?; + if content.trim().is_empty() { + return Ok(()); + } + + let mut doc: toml::Value = toml::from_str(&content)?; + + if let Some(channels) = doc + .as_table_mut() + .and_then(|r| r.get_mut("channels")) + .and_then(|c| c.as_table_mut()) + { + channels.remove(channel_name); + } + + std::fs::write(config_path, toml::to_string_pretty(&doc)?)?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Integration management endpoints +// --------------------------------------------------------------------------- + +/// GET /api/integrations — List installed integrations with status. +pub async fn list_integrations(State(state): State>) -> impl IntoResponse { + let registry = state + .kernel + .extension_registry + .read() + .unwrap_or_else(|e| e.into_inner()); + let health = &state.kernel.extension_health; + + let mut entries = Vec::new(); + for info in registry.list_all_info() { + let h = health.get_health(&info.template.id); + let status = match &info.installed { + Some(inst) if !inst.enabled => "disabled", + Some(_) => match h.as_ref().map(|h| &h.status) { + Some(openfang_extensions::IntegrationStatus::Ready) => "ready", + Some(openfang_extensions::IntegrationStatus::Error(_)) => "error", + _ => "installed", + }, + None => continue, // Only show installed + }; + entries.push(serde_json::json!({ + "id": info.template.id, + "name": info.template.name, + "icon": info.template.icon, + "category": info.template.category.to_string(), + "status": status, + "tool_count": h.as_ref().map(|h| h.tool_count).unwrap_or(0), + "installed_at": info.installed.as_ref().map(|i| i.installed_at.to_rfc3339()), + })); + } + + Json(serde_json::json!({ + "installed": entries, + "count": entries.len(), + })) +} + +/// GET /api/integrations/available — List all available templates. +pub async fn list_available_integrations(State(state): State>) -> impl IntoResponse { + let registry = state + .kernel + .extension_registry + .read() + .unwrap_or_else(|e| e.into_inner()); + let templates: Vec = registry + .list_templates() + .iter() + .map(|t| { + let installed = registry.is_installed(&t.id); + serde_json::json!({ + "id": t.id, + "name": t.name, + "description": t.description, + "icon": t.icon, + "category": t.category.to_string(), + "installed": installed, + "tags": t.tags, + "required_env": t.required_env.iter().map(|e| serde_json::json!({ + "name": e.name, + "label": e.label, + "help": e.help, + "is_secret": e.is_secret, + "get_url": e.get_url, + })).collect::>(), + "has_oauth": t.oauth.is_some(), + "setup_instructions": t.setup_instructions, + }) + }) + .collect(); + + Json(serde_json::json!({ + "integrations": templates, + "count": templates.len(), + })) +} + +/// POST /api/integrations/add — Install an integration. +pub async fn add_integration( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + let id = match req.get("id").and_then(|v| v.as_str()) { + Some(id) => id.to_string(), + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing 'id' field"})), + ); + } + }; + + // Scope the write lock so it's dropped before any .await + let install_err = { + let mut registry = state + .kernel + .extension_registry + .write() + .unwrap_or_else(|e| e.into_inner()); + + if registry.is_installed(&id) { + Some(( + StatusCode::CONFLICT, + format!("Integration '{}' already installed", id), + )) + } else if registry.get_template(&id).is_none() { + Some(( + StatusCode::NOT_FOUND, + format!("Unknown integration: '{}'", id), + )) + } else { + let entry = openfang_extensions::InstalledIntegration { + id: id.clone(), + installed_at: chrono::Utc::now(), + enabled: true, + oauth_provider: None, + config: std::collections::HashMap::new(), + }; + match registry.install(entry) { + Ok(_) => None, + Err(e) => Some((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), + } + } + }; // write lock dropped here + + if let Some((status, error)) = install_err { + return (status, Json(serde_json::json!({"error": error}))); + } + + state.kernel.extension_health.register(&id); + + // Hot-connect the new MCP server + let connected = state.kernel.reload_extension_mcps().await.unwrap_or(0); + + ( + StatusCode::CREATED, + Json(serde_json::json!({ + "id": id, + "status": "installed", + "connected": connected > 0, + "message": format!("Integration '{}' installed", id), + })), + ) +} + +/// DELETE /api/integrations/:id — Remove an integration. +pub async fn remove_integration( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + // Scope the write lock + let uninstall_err = { + let mut registry = state + .kernel + .extension_registry + .write() + .unwrap_or_else(|e| e.into_inner()); + registry.uninstall(&id).err() + }; + + if let Some(e) = uninstall_err { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": e.to_string()})), + ); + } + + state.kernel.extension_health.unregister(&id); + + // Hot-disconnect the removed MCP server + let _ = state.kernel.reload_extension_mcps().await; + + ( + StatusCode::OK, + Json(serde_json::json!({ + "id": id, + "status": "removed", + })), + ) +} + +/// POST /api/integrations/:id/reconnect — Reconnect an MCP server. +pub async fn reconnect_integration( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let is_installed = { + let registry = state + .kernel + .extension_registry + .read() + .unwrap_or_else(|e| e.into_inner()); + registry.is_installed(&id) + }; + + if !is_installed { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("Integration '{}' not installed", id)})), + ); + } + + match state.kernel.reconnect_extension_mcp(&id).await { + Ok(tool_count) => ( + StatusCode::OK, + Json(serde_json::json!({ + "id": id, + "status": "connected", + "tool_count": tool_count, + })), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "id": id, + "status": "error", + "error": e, + })), + ), + } +} + +/// GET /api/integrations/health — Health status for all integrations. +pub async fn integrations_health(State(state): State>) -> impl IntoResponse { + let health_entries = state.kernel.extension_health.all_health(); + let entries: Vec = health_entries + .iter() + .map(|h| { + serde_json::json!({ + "id": h.id, + "status": h.status.to_string(), + "tool_count": h.tool_count, + "last_ok": h.last_ok.map(|t| t.to_rfc3339()), + "last_error": h.last_error, + "consecutive_failures": h.consecutive_failures, + "reconnecting": h.reconnecting, + "reconnect_attempts": h.reconnect_attempts, + "connected_since": h.connected_since.map(|t| t.to_rfc3339()), + }) + }) + .collect(); + + Json(serde_json::json!({ + "health": entries, + "count": entries.len(), + })) +} + +/// POST /api/integrations/reload — Hot-reload integration configs and reconnect MCP. +pub async fn reload_integrations(State(state): State>) -> impl IntoResponse { + match state.kernel.reload_extension_mcps().await { + Ok(connected) => ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "reloaded", + "new_connections": connected, + })), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e})), + ), + } +} + +// --------------------------------------------------------------------------- +// Scheduled Jobs (cron) endpoints +// --------------------------------------------------------------------------- + +/// The well-known shared-memory agent ID used for cross-agent KV storage. +/// Must match the value in `openfang-kernel/src/kernel.rs::shared_memory_agent_id()`. +fn schedule_shared_agent_id() -> AgentId { + AgentId(uuid::Uuid::from_bytes([ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, + ])) +} + +const SCHEDULES_KEY: &str = "__openfang_schedules"; + +/// GET /api/schedules — List all cron-based scheduled jobs. +pub async fn list_schedules(State(state): State>) -> impl IntoResponse { + let agent_id = schedule_shared_agent_id(); + match state.kernel.memory.structured_get(agent_id, SCHEDULES_KEY) { + Ok(Some(serde_json::Value::Array(arr))) => { + let total = arr.len(); + Json(serde_json::json!({"schedules": arr, "total": total})) + } + Ok(_) => Json(serde_json::json!({"schedules": [], "total": 0})), + Err(e) => { + tracing::warn!("Failed to load schedules: {e}"); + Json(serde_json::json!({"schedules": [], "total": 0, "error": format!("{e}")})) + } + } +} + +/// POST /api/schedules — Create a new cron-based scheduled job. +pub async fn create_schedule( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + let name = match req["name"].as_str() { + Some(n) if !n.is_empty() => n.to_string(), + _ => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing 'name' field"})), + ); + } + }; + + let cron = match req["cron"].as_str() { + Some(c) if !c.is_empty() => c.to_string(), + _ => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing 'cron' field"})), + ); + } + }; + + // Validate cron expression: must be 5 space-separated fields + let cron_parts: Vec<&str> = cron.split_whitespace().collect(); + if cron_parts.len() != 5 { + return ( + StatusCode::BAD_REQUEST, + Json( + serde_json::json!({"error": "Invalid cron expression: must have 5 fields (min hour dom mon dow)"}), + ), + ); + } + + let agent_id_str = req["agent_id"].as_str().unwrap_or("").to_string(); + if agent_id_str.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing required field: agent_id"})), + ); + } + // Validate agent exists (UUID or name lookup) + let agent_exists = if let Ok(aid) = agent_id_str.parse::() { + state.kernel.registry.get(aid).is_some() + } else { + state + .kernel + .registry + .list() + .iter() + .any(|a| a.name == agent_id_str) + }; + if !agent_exists { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("Agent not found: {agent_id_str}")})), + ); + } + let message = req["message"].as_str().unwrap_or("").to_string(); + let enabled = req.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true); + + let schedule_id = uuid::Uuid::new_v4().to_string(); + let entry = serde_json::json!({ + "id": schedule_id, + "name": name, + "cron": cron, + "agent_id": agent_id_str, + "message": message, + "enabled": enabled, + "created_at": chrono::Utc::now().to_rfc3339(), + "last_run": null, + "run_count": 0, + }); + + let shared_id = schedule_shared_agent_id(); + let mut schedules: Vec = + match state.kernel.memory.structured_get(shared_id, SCHEDULES_KEY) { + Ok(Some(serde_json::Value::Array(arr))) => arr, + _ => Vec::new(), + }; + + schedules.push(entry.clone()); + if let Err(e) = state.kernel.memory.structured_set( + shared_id, + SCHEDULES_KEY, + serde_json::Value::Array(schedules), + ) { + tracing::warn!("Failed to save schedule: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Failed to save schedule: {e}")})), + ); + } + + (StatusCode::CREATED, Json(entry)) +} + +/// PUT /api/schedules/:id — Update a scheduled job (toggle enabled, edit fields). +pub async fn update_schedule( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let shared_id = schedule_shared_agent_id(); + let mut schedules: Vec = + match state.kernel.memory.structured_get(shared_id, SCHEDULES_KEY) { + Ok(Some(serde_json::Value::Array(arr))) => arr, + _ => Vec::new(), + }; + + let mut found = false; + for s in schedules.iter_mut() { + if s["id"].as_str() == Some(&id) { + found = true; + if let Some(enabled) = req.get("enabled").and_then(|v| v.as_bool()) { + s["enabled"] = serde_json::Value::Bool(enabled); + } + if let Some(name) = req.get("name").and_then(|v| v.as_str()) { + s["name"] = serde_json::Value::String(name.to_string()); + } + if let Some(cron) = req.get("cron").and_then(|v| v.as_str()) { + let cron_parts: Vec<&str> = cron.split_whitespace().collect(); + if cron_parts.len() != 5 { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid cron expression"})), + ); + } + s["cron"] = serde_json::Value::String(cron.to_string()); + } + if let Some(agent_id) = req.get("agent_id").and_then(|v| v.as_str()) { + s["agent_id"] = serde_json::Value::String(agent_id.to_string()); + } + if let Some(message) = req.get("message").and_then(|v| v.as_str()) { + s["message"] = serde_json::Value::String(message.to_string()); + } + break; + } + } + + if !found { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Schedule not found"})), + ); + } + + if let Err(e) = state.kernel.memory.structured_set( + shared_id, + SCHEDULES_KEY, + serde_json::Value::Array(schedules), + ) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Failed to update schedule: {e}")})), + ); + } + + ( + StatusCode::OK, + Json(serde_json::json!({"status": "updated", "schedule_id": id})), + ) +} + +/// DELETE /api/schedules/:id — Remove a scheduled job. +pub async fn delete_schedule( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let shared_id = schedule_shared_agent_id(); + let mut schedules: Vec = + match state.kernel.memory.structured_get(shared_id, SCHEDULES_KEY) { + Ok(Some(serde_json::Value::Array(arr))) => arr, + _ => Vec::new(), + }; + + let before = schedules.len(); + schedules.retain(|s| s["id"].as_str() != Some(&id)); + + if schedules.len() == before { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Schedule not found"})), + ); + } + + if let Err(e) = state.kernel.memory.structured_set( + shared_id, + SCHEDULES_KEY, + serde_json::Value::Array(schedules), + ) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Failed to delete schedule: {e}")})), + ); + } + + ( + StatusCode::OK, + Json(serde_json::json!({"status": "removed", "schedule_id": id})), + ) +} + +/// POST /api/schedules/:id/run — Manually run a scheduled job now. +pub async fn run_schedule( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let shared_id = schedule_shared_agent_id(); + let schedules: Vec = + match state.kernel.memory.structured_get(shared_id, SCHEDULES_KEY) { + Ok(Some(serde_json::Value::Array(arr))) => arr, + _ => Vec::new(), + }; + + let schedule = match schedules.iter().find(|s| s["id"].as_str() == Some(&id)) { + Some(s) => s.clone(), + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Schedule not found"})), + ); + } + }; + + let agent_id_str = schedule["agent_id"].as_str().unwrap_or(""); + let message = schedule["message"] + .as_str() + .unwrap_or("Scheduled task triggered manually."); + let name = schedule["name"].as_str().unwrap_or("(unnamed)"); + + // Find the target agent — require explicit agent_id, no silent fallback + let target_agent = if !agent_id_str.is_empty() { + if let Ok(aid) = agent_id_str.parse::() { + if state.kernel.registry.get(aid).is_some() { + Some(aid) + } else { + None + } + } else { + state + .kernel + .registry + .list() + .iter() + .find(|a| a.name == agent_id_str) + .map(|a| a.id) + } + } else { + None + }; + + let target_agent = match target_agent { + Some(a) => a, + None => { + return ( + StatusCode::NOT_FOUND, + Json( + serde_json::json!({"error": "No target agent found. Specify an agent_id or start an agent first."}), + ), + ); + } + }; + + let run_message = if message.is_empty() { + format!("[Scheduled task '{}' triggered manually]", name) + } else { + message.to_string() + }; + + // Update last_run and run_count + let mut schedules_updated: Vec = + match state.kernel.memory.structured_get(shared_id, SCHEDULES_KEY) { + Ok(Some(serde_json::Value::Array(arr))) => arr, + _ => Vec::new(), + }; + for s in schedules_updated.iter_mut() { + if s["id"].as_str() == Some(&id) { + s["last_run"] = serde_json::Value::String(chrono::Utc::now().to_rfc3339()); + let count = s["run_count"].as_u64().unwrap_or(0); + s["run_count"] = serde_json::json!(count + 1); + break; + } + } + let _ = state.kernel.memory.structured_set( + shared_id, + SCHEDULES_KEY, + serde_json::Value::Array(schedules_updated), + ); + + let kernel_handle: Arc = state.kernel.clone() as Arc; + match state + .kernel + .send_message_with_handle(target_agent, &run_message, Some(kernel_handle)) + .await + { + Ok(result) => ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "completed", + "schedule_id": id, + "agent_id": target_agent.to_string(), + "response": result.response, + })), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "status": "failed", + "schedule_id": id, + "error": format!("{e}"), + })), + ), + } +} + +// --------------------------------------------------------------------------- +// Agent Identity endpoint +// --------------------------------------------------------------------------- + +/// Request body for updating agent visual identity. +#[derive(serde::Deserialize)] +pub struct UpdateIdentityRequest { + pub emoji: Option, + pub avatar_url: Option, + pub color: Option, + #[serde(default)] + pub archetype: Option, + #[serde(default)] + pub vibe: Option, + #[serde(default)] + pub greeting_style: Option, +} + +/// PATCH /api/agents/{id}/identity — Update an agent's visual identity. +pub async fn update_agent_identity( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ); + } + }; + + // Validate color format if provided + if let Some(ref color) = req.color { + if !color.is_empty() && !color.starts_with('#') { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Color must be a hex code starting with '#'"})), + ); + } + } + + // Validate avatar_url if provided + if let Some(ref url) = req.avatar_url { + if !url.is_empty() + && !url.starts_with("http://") + && !url.starts_with("https://") + && !url.starts_with("data:") + { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Avatar URL must be http/https or data URI"})), + ); + } + } + + let identity = AgentIdentity { + emoji: req.emoji, + avatar_url: req.avatar_url, + color: req.color, + archetype: req.archetype, + vibe: req.vibe, + greeting_style: req.greeting_style, + }; + + match state.kernel.registry.update_identity(agent_id, identity) { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "ok", "agent_id": id})), + ), + Err(_) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ), + } +} + +// --------------------------------------------------------------------------- +// Agent Config Hot-Update +// --------------------------------------------------------------------------- + +/// Request body for patching agent config (name, description, prompt, identity, model). +#[derive(serde::Deserialize)] +pub struct PatchAgentConfigRequest { + pub name: Option, + pub description: Option, + pub system_prompt: Option, + pub emoji: Option, + pub avatar_url: Option, + pub color: Option, + pub archetype: Option, + pub vibe: Option, + pub greeting_style: Option, + pub model: Option, + pub provider: Option, + pub api_key_env: Option, + pub base_url: Option, +} + +/// PATCH /api/agents/{id}/config — Hot-update agent name, description, system prompt, and identity. +pub async fn patch_agent_config( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ); + } + }; + + // Input length limits + const MAX_NAME_LEN: usize = 256; + const MAX_DESC_LEN: usize = 4096; + const MAX_PROMPT_LEN: usize = 65_536; + + if let Some(ref name) = req.name { + if name.len() > MAX_NAME_LEN { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + Json( + serde_json::json!({"error": format!("Name exceeds max length ({MAX_NAME_LEN} chars)")}), + ), + ); + } + } + if let Some(ref desc) = req.description { + if desc.len() > MAX_DESC_LEN { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + Json( + serde_json::json!({"error": format!("Description exceeds max length ({MAX_DESC_LEN} chars)")}), + ), + ); + } + } + if let Some(ref prompt) = req.system_prompt { + if prompt.len() > MAX_PROMPT_LEN { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + Json( + serde_json::json!({"error": format!("System prompt exceeds max length ({MAX_PROMPT_LEN} chars)")}), + ), + ); + } + } + + // Validate color format if provided + if let Some(ref color) = req.color { + if !color.is_empty() && !color.starts_with('#') { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Color must be a hex code starting with '#'"})), + ); + } + } + + // Validate avatar_url if provided + if let Some(ref url) = req.avatar_url { + if !url.is_empty() + && !url.starts_with("http://") + && !url.starts_with("https://") + && !url.starts_with("data:") + { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Avatar URL must be http/https or data URI"})), + ); + } + } + + // Update name + if let Some(ref new_name) = req.name { + if !new_name.is_empty() { + if let Err(e) = state + .kernel + .registry + .update_name(agent_id, new_name.clone()) + { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({"error": format!("{e}")})), + ); + } + } + } + + // Update description + if let Some(ref new_desc) = req.description { + if state + .kernel + .registry + .update_description(agent_id, new_desc.clone()) + .is_err() + { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ); + } + } + + // Update system prompt (hot-swap — takes effect on next message) + if let Some(ref new_prompt) = req.system_prompt { + if state + .kernel + .registry + .update_system_prompt(agent_id, new_prompt.clone()) + .is_err() + { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ); + } + } + + // Update identity fields (merge — only overwrite provided fields) + let has_identity_field = req.emoji.is_some() + || req.avatar_url.is_some() + || req.color.is_some() + || req.archetype.is_some() + || req.vibe.is_some() + || req.greeting_style.is_some(); + + if has_identity_field { + // Read current identity, merge with provided fields + let current = state + .kernel + .registry + .get(agent_id) + .map(|e| e.identity) + .unwrap_or_default(); + let merged = AgentIdentity { + emoji: req.emoji.or(current.emoji), + avatar_url: req.avatar_url.or(current.avatar_url), + color: req.color.or(current.color), + archetype: req.archetype.or(current.archetype), + vibe: req.vibe.or(current.vibe), + greeting_style: req.greeting_style.or(current.greeting_style), + }; + if state + .kernel + .registry + .update_identity(agent_id, merged) + .is_err() + { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ); + } + } + + // Update model/provider + if let Some(ref new_model) = req.model { + if !new_model.is_empty() { + if let Some(ref new_provider) = req.provider { + if !new_provider.is_empty() { + if state + .kernel + .registry + .update_model_and_provider( + agent_id, + new_model.clone(), + new_provider.clone(), + ) + .is_err() + { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ); + } + } else if state + .kernel + .registry + .update_model(agent_id, new_model.clone()) + .is_err() + { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ); + } + } else if state + .kernel + .registry + .update_model(agent_id, new_model.clone()) + .is_err() + { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ); + } + } + } + + // Persist updated manifest to database so changes survive restart + if let Some(entry) = state.kernel.registry.get(agent_id) { + if let Err(e) = state.kernel.memory.save_agent(&entry) { + tracing::warn!("Failed to persist agent config update: {e}"); + } + } + + ( + StatusCode::OK, + Json(serde_json::json!({"status": "ok", "agent_id": id})), + ) +} + +// --------------------------------------------------------------------------- +// Agent Cloning +// --------------------------------------------------------------------------- + +/// Request body for cloning an agent. +#[derive(serde::Deserialize)] +pub struct CloneAgentRequest { + pub new_name: String, +} + +/// POST /api/agents/{id}/clone — Clone an agent with its workspace files. +pub async fn clone_agent( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ); + } + }; + + if req.new_name.len() > 256 { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + Json(serde_json::json!({"error": "Name exceeds max length (256 chars)"})), + ); + } + + if req.new_name.trim().is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "new_name cannot be empty"})), + ); + } + + let source = match state.kernel.registry.get(agent_id) { + Some(e) => e, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ); + } + }; + + // Deep-clone manifest with new name + let mut cloned_manifest = source.manifest.clone(); + cloned_manifest.name = req.new_name.clone(); + cloned_manifest.workspace = None; // Let kernel assign a new workspace + + // Spawn the cloned agent + let new_id = match state.kernel.spawn_agent(cloned_manifest) { + Ok(id) => id, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Clone spawn failed: {e}")})), + ); + } + }; + + // Copy workspace files from source to destination + let new_entry = state.kernel.registry.get(new_id); + if let (Some(ref src_ws), Some(ref new_entry)) = (source.manifest.workspace, new_entry) { + if let Some(ref dst_ws) = new_entry.manifest.workspace { + // Security: canonicalize both paths + if let (Ok(src_can), Ok(dst_can)) = (src_ws.canonicalize(), dst_ws.canonicalize()) { + for &fname in KNOWN_IDENTITY_FILES { + let src_file = src_can.join(fname); + let dst_file = dst_can.join(fname); + if src_file.exists() { + let _ = std::fs::copy(&src_file, &dst_file); + } + } + } + } + } + + // Copy identity from source + let _ = state + .kernel + .registry + .update_identity(new_id, source.identity.clone()); + + ( + StatusCode::CREATED, + Json(serde_json::json!({ + "agent_id": new_id.to_string(), + "name": req.new_name, + })), + ) +} + +// --------------------------------------------------------------------------- +// Workspace File Editor endpoints +// --------------------------------------------------------------------------- + +/// Whitelisted workspace identity files that can be read/written via API. +const KNOWN_IDENTITY_FILES: &[&str] = &[ + "SOUL.md", + "IDENTITY.md", + "USER.md", + "TOOLS.md", + "MEMORY.md", + "AGENTS.md", + "BOOTSTRAP.md", + "HEARTBEAT.md", +]; + +/// GET /api/agents/{id}/files — List workspace identity files. +pub async fn list_agent_files( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ); + } + }; + + let entry = match state.kernel.registry.get(agent_id) { + Some(e) => e, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ); + } + }; + + let workspace = match entry.manifest.workspace { + Some(ref ws) => ws.clone(), + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent has no workspace"})), + ); + } + }; + + let mut files = Vec::new(); + for &name in KNOWN_IDENTITY_FILES { + let path = workspace.join(name); + let (exists, size_bytes) = if path.exists() { + let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + (true, size) + } else { + (false, 0u64) + }; + files.push(serde_json::json!({ + "name": name, + "exists": exists, + "size_bytes": size_bytes, + })); + } + + (StatusCode::OK, Json(serde_json::json!({ "files": files }))) +} + +/// GET /api/agents/{id}/files/{filename} — Read a workspace identity file. +pub async fn get_agent_file( + State(state): State>, + Path((id, filename)): Path<(String, String)>, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ); + } + }; + + // Validate filename whitelist + if !KNOWN_IDENTITY_FILES.contains(&filename.as_str()) { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "File not in whitelist"})), + ); + } + + let entry = match state.kernel.registry.get(agent_id) { + Some(e) => e, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ); + } + }; + + let workspace = match entry.manifest.workspace { + Some(ref ws) => ws.clone(), + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent has no workspace"})), + ); + } + }; + + // Security: canonicalize and verify stays inside workspace + let file_path = workspace.join(&filename); + let canonical = match file_path.canonicalize() { + Ok(p) => p, + Err(_) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "File not found"})), + ); + } + }; + let ws_canonical = match workspace.canonicalize() { + Ok(p) => p, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Workspace path error"})), + ); + } + }; + if !canonical.starts_with(&ws_canonical) { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({"error": "Path traversal denied"})), + ); + } + + let content = match std::fs::read_to_string(&canonical) { + Ok(c) => c, + Err(_) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "File not found"})), + ); + } + }; + + let size_bytes = content.len(); + ( + StatusCode::OK, + Json(serde_json::json!({ + "name": filename, + "content": content, + "size_bytes": size_bytes, + })), + ) +} + +/// Request body for writing a workspace identity file. +#[derive(serde::Deserialize)] +pub struct SetAgentFileRequest { + pub content: String, +} + +/// PUT /api/agents/{id}/files/{filename} — Write a workspace identity file. +pub async fn set_agent_file( + State(state): State>, + Path((id, filename)): Path<(String, String)>, + Json(req): Json, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ); + } + }; + + // Validate filename whitelist + if !KNOWN_IDENTITY_FILES.contains(&filename.as_str()) { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "File not in whitelist"})), + ); + } + + // Max 32KB content + const MAX_FILE_SIZE: usize = 32_768; + if req.content.len() > MAX_FILE_SIZE { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + Json(serde_json::json!({"error": "File content too large (max 32KB)"})), + ); + } + + let entry = match state.kernel.registry.get(agent_id) { + Some(e) => e, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ); + } + }; + + let workspace = match entry.manifest.workspace { + Some(ref ws) => ws.clone(), + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent has no workspace"})), + ); + } + }; + + // Security: verify workspace path and target stays inside it + let ws_canonical = match workspace.canonicalize() { + Ok(p) => p, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Workspace path error"})), + ); + } + }; + + let file_path = workspace.join(&filename); + // For new files, check the parent directory instead + let check_path = if file_path.exists() { + file_path + .canonicalize() + .unwrap_or_else(|_| file_path.clone()) + } else { + // Parent must be inside workspace + file_path + .parent() + .and_then(|p| p.canonicalize().ok()) + .map(|p| p.join(&filename)) + .unwrap_or_else(|| file_path.clone()) + }; + if !check_path.starts_with(&ws_canonical) { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({"error": "Path traversal denied"})), + ); + } + + // Atomic write: write to .tmp, then rename + let tmp_path = workspace.join(format!(".{filename}.tmp")); + if let Err(e) = std::fs::write(&tmp_path, &req.content) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Write failed: {e}")})), + ); + } + if let Err(e) = std::fs::rename(&tmp_path, &file_path) { + let _ = std::fs::remove_file(&tmp_path); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Rename failed: {e}")})), + ); + } + + let size_bytes = req.content.len(); + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "ok", + "name": filename, + "size_bytes": size_bytes, + })), + ) +} + +// --------------------------------------------------------------------------- +// File Upload endpoints +// --------------------------------------------------------------------------- + +/// Response body for file uploads. +#[derive(serde::Serialize)] +struct UploadResponse { + file_id: String, + filename: String, + content_type: String, + size: usize, + /// Transcription text for audio uploads (populated via Whisper STT). + #[serde(skip_serializing_if = "Option::is_none")] + transcription: Option, +} + +/// Metadata stored alongside uploaded files. +struct UploadMeta { + #[allow(dead_code)] + filename: String, + content_type: String, +} + +/// In-memory upload metadata registry. +static UPLOAD_REGISTRY: LazyLock> = LazyLock::new(DashMap::new); + +/// Maximum upload size: 10 MB. +const MAX_UPLOAD_SIZE: usize = 10 * 1024 * 1024; + +/// Allowed content type prefixes for upload. +const ALLOWED_CONTENT_TYPES: &[&str] = &["image/", "text/", "application/pdf", "audio/"]; + +fn is_allowed_content_type(ct: &str) -> bool { + ALLOWED_CONTENT_TYPES + .iter() + .any(|prefix| ct.starts_with(prefix)) +} + +/// POST /api/agents/{id}/upload — Upload a file attachment. +/// +/// Accepts raw body bytes. The client must set: +/// - `Content-Type` header (e.g., `image/png`, `text/plain`, `application/pdf`) +/// - `X-Filename` header (original filename) +pub async fn upload_file( + State(state): State>, + Path(id): Path, + headers: axum::http::HeaderMap, + body: axum::body::Bytes, +) -> impl IntoResponse { + // Validate agent ID format + let _agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent ID"})), + ); + } + }; + + // Extract content type + let content_type = headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + + if !is_allowed_content_type(&content_type) { + return ( + StatusCode::BAD_REQUEST, + Json( + serde_json::json!({"error": "Unsupported content type. Allowed: image/*, text/*, audio/*, application/pdf"}), + ), + ); + } + + // Extract filename from header + let filename = headers + .get("X-Filename") + .and_then(|v| v.to_str().ok()) + .unwrap_or("upload") + .to_string(); + + // Validate size + if body.len() > MAX_UPLOAD_SIZE { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + Json( + serde_json::json!({"error": format!("File too large (max {} MB)", MAX_UPLOAD_SIZE / (1024 * 1024))}), + ), + ); + } + + if body.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Empty file body"})), + ); + } + + // Generate file ID and save + let file_id = uuid::Uuid::new_v4().to_string(); + let upload_dir = std::env::temp_dir().join("openfang_uploads"); + if let Err(e) = std::fs::create_dir_all(&upload_dir) { + tracing::warn!("Failed to create upload dir: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Failed to create upload directory"})), + ); + } + + let file_path = upload_dir.join(&file_id); + if let Err(e) = std::fs::write(&file_path, &body) { + tracing::warn!("Failed to write upload: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Failed to save file"})), + ); + } + + let size = body.len(); + UPLOAD_REGISTRY.insert( + file_id.clone(), + UploadMeta { + filename: filename.clone(), + content_type: content_type.clone(), + }, + ); + + // Auto-transcribe audio uploads using the media engine + let transcription = if content_type.starts_with("audio/") { + let attachment = openfang_types::media::MediaAttachment { + media_type: openfang_types::media::MediaType::Audio, + mime_type: content_type.clone(), + source: openfang_types::media::MediaSource::FilePath { + path: file_path.to_string_lossy().to_string(), + }, + size_bytes: size as u64, + }; + match state + .kernel + .media_engine + .transcribe_audio(&attachment) + .await + { + Ok(result) => { + tracing::info!(chars = result.description.len(), provider = %result.provider, "Audio transcribed"); + Some(result.description) + } + Err(e) => { + tracing::warn!("Audio transcription failed: {e}"); + None + } + } + } else { + None + }; + + ( + StatusCode::CREATED, + Json(serde_json::json!(UploadResponse { + file_id, + filename, + content_type, + size, + transcription, + })), + ) +} + +/// GET /api/uploads/{file_id} — Serve an uploaded file. +pub async fn serve_upload(Path(file_id): Path) -> impl IntoResponse { + // Validate file_id is a UUID to prevent path traversal + if uuid::Uuid::parse_str(&file_id).is_err() { + return ( + StatusCode::BAD_REQUEST, + [( + axum::http::header::CONTENT_TYPE, + "application/json".to_string(), + )], + b"{\"error\":\"Invalid file ID\"}".to_vec(), + ); + } + + let file_path = std::env::temp_dir().join("openfang_uploads").join(&file_id); + + // Look up metadata from registry; fall back to disk probe for generated images + // (image_generate saves files without registering in UPLOAD_REGISTRY). + let content_type = match UPLOAD_REGISTRY.get(&file_id) { + Some(m) => m.content_type.clone(), + None => { + // Infer content type from file magic bytes + if !file_path.exists() { + return ( + StatusCode::NOT_FOUND, + [( + axum::http::header::CONTENT_TYPE, + "application/json".to_string(), + )], + b"{\"error\":\"File not found\"}".to_vec(), + ); + } + "image/png".to_string() + } + }; + + match std::fs::read(&file_path) { + Ok(data) => ( + StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, content_type)], + data, + ), + Err(_) => ( + StatusCode::NOT_FOUND, + [( + axum::http::header::CONTENT_TYPE, + "application/json".to_string(), + )], + b"{\"error\":\"File not found on disk\"}".to_vec(), + ), + } +} + +// --------------------------------------------------------------------------- +// Execution Approval System — backed by kernel.approval_manager +// --------------------------------------------------------------------------- + +/// GET /api/approvals — List pending approval requests. +/// +/// Transforms field names to match the dashboard template expectations: +/// `action_summary` → `action`, `agent_id` → `agent_name`, `requested_at` → `created_at`. +pub async fn list_approvals(State(state): State>) -> impl IntoResponse { + let pending = state.kernel.approval_manager.list_pending(); + let total = pending.len(); + + // Resolve agent names for display + let registry_agents = state.kernel.registry.list(); + + let approvals: Vec = pending + .into_iter() + .map(|a| { + let agent_name = registry_agents + .iter() + .find(|ag| ag.id.to_string() == a.agent_id || ag.name == a.agent_id) + .map(|ag| ag.name.as_str()) + .unwrap_or(&a.agent_id); + serde_json::json!({ + "id": a.id, + "agent_id": a.agent_id, + "agent_name": agent_name, + "tool_name": a.tool_name, + "description": a.description, + "action_summary": a.action_summary, + "action": a.action_summary, + "risk_level": a.risk_level, + "requested_at": a.requested_at, + "created_at": a.requested_at, + "timeout_secs": a.timeout_secs, + "status": "pending" + }) + }) + .collect(); + + Json(serde_json::json!({"approvals": approvals, "total": total})) +} + +/// POST /api/approvals — Create a manual approval request (for external systems). +/// +/// Note: Most approval requests are created automatically by the tool_runner +/// when an agent invokes a tool that requires approval. This endpoint exists +/// for external integrations that need to inject approval gates. +#[derive(serde::Deserialize)] +pub struct CreateApprovalRequest { + pub agent_id: String, + pub tool_name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub action_summary: String, +} + +pub async fn create_approval( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + use openfang_types::approval::{ApprovalRequest, RiskLevel}; + + let policy = state.kernel.approval_manager.policy(); + let id = uuid::Uuid::new_v4(); + let approval_req = ApprovalRequest { + id, + agent_id: req.agent_id, + tool_name: req.tool_name.clone(), + description: if req.description.is_empty() { + format!("Manual approval request for {}", req.tool_name) + } else { + req.description + }, + action_summary: if req.action_summary.is_empty() { + req.tool_name.clone() + } else { + req.action_summary + }, + risk_level: RiskLevel::High, + requested_at: chrono::Utc::now(), + timeout_secs: policy.timeout_secs, + }; + + // Spawn the request in the background (it will block until resolved or timed out) + let kernel = Arc::clone(&state.kernel); + tokio::spawn(async move { + kernel.approval_manager.request_approval(approval_req).await; + }); + + ( + StatusCode::CREATED, + Json(serde_json::json!({"id": id.to_string(), "status": "pending"})), + ) +} + +/// POST /api/approvals/{id}/approve — Approve a pending request. +pub async fn approve_request( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let uuid = match uuid::Uuid::parse_str(&id) { + Ok(u) => u, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid approval ID"})), + ); + } + }; + + match state.kernel.approval_manager.resolve( + uuid, + openfang_types::approval::ApprovalDecision::Approved, + Some("api".to_string()), + ) { + Ok(resp) => ( + StatusCode::OK, + Json( + serde_json::json!({"id": id, "status": "approved", "decided_at": resp.decided_at.to_rfc3339()}), + ), + ), + Err(e) => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": e}))), + } +} + +/// POST /api/approvals/{id}/reject — Reject a pending request. +pub async fn reject_request( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let uuid = match uuid::Uuid::parse_str(&id) { + Ok(u) => u, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid approval ID"})), + ); + } + }; + + match state.kernel.approval_manager.resolve( + uuid, + openfang_types::approval::ApprovalDecision::Denied, + Some("api".to_string()), + ) { + Ok(resp) => ( + StatusCode::OK, + Json( + serde_json::json!({"id": id, "status": "rejected", "decided_at": resp.decided_at.to_rfc3339()}), + ), + ), + Err(e) => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": e}))), + } +} + +// --------------------------------------------------------------------------- +// Config Reload endpoint +// --------------------------------------------------------------------------- + +/// POST /api/config/reload — Reload configuration from disk and apply hot-reloadable changes. +/// +/// Reads the config file, diffs against current config, validates the new config, +/// and applies hot-reloadable actions (approval policy, cron limits, etc.). +/// Returns the reload plan showing what changed and what was applied. +pub async fn config_reload(State(state): State>) -> impl IntoResponse { + // SECURITY: Record config reload in audit trail + state.kernel.audit_log.record( + "system", + openfang_runtime::audit::AuditAction::ConfigChange, + "config reload requested via API", + "pending", + ); + match state.kernel.reload_config() { + Ok(plan) => { + let status = if plan.restart_required { + "partial" + } else if plan.has_changes() { + "applied" + } else { + "no_changes" + }; + + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": status, + "restart_required": plan.restart_required, + "restart_reasons": plan.restart_reasons, + "hot_actions_applied": plan.hot_actions.iter().map(|a| format!("{a:?}")).collect::>(), + "noop_changes": plan.noop_changes, + })), + ) + } + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"status": "error", "error": e})), + ), + } +} + +// --------------------------------------------------------------------------- +// Config Schema endpoint +// --------------------------------------------------------------------------- + +/// GET /api/config/schema — Return a simplified JSON description of the config structure. +pub async fn config_schema(State(state): State>) -> impl IntoResponse { + // Build provider/model options from model catalog for dropdowns + let catalog = state + .kernel + .model_catalog + .read() + .unwrap_or_else(|e| e.into_inner()); + let provider_options: Vec = catalog + .list_providers() + .iter() + .map(|p| p.id.clone()) + .collect(); + let model_options: Vec = catalog + .list_models() + .iter() + .map(|m| serde_json::json!({"id": m.id, "name": m.display_name, "provider": m.provider})) + .collect(); + drop(catalog); + + Json(serde_json::json!({ + "sections": { + "api": { + "fields": { + "api_listen": "string", + "api_key": "string", + "log_level": "string" + } + }, + "default_model": { + "hot_reloadable": true, + "fields": { + "provider": { "type": "select", "options": provider_options }, + "model": { "type": "select", "options": model_options }, + "api_key_env": "string", + "base_url": "string" + } + }, + "memory": { + "fields": { + "decay_rate": "number", + "vector_dims": "number" + } + }, + "web": { + "fields": { + "provider": "string", + "timeout_secs": "number", + "max_results": "number" + } + }, + "browser": { + "fields": { + "headless": "boolean", + "timeout_secs": "number", + "executable_path": "string" + } + }, + "network": { + "fields": { + "enabled": "boolean", + "listen_addr": "string", + "shared_secret": "string" + } + }, + "extensions": { + "fields": { + "auto_connect": "boolean", + "health_check_interval_secs": "number" + } + }, + "vault": { + "fields": { + "path": "string" + } + }, + "a2a": { + "fields": { + "enabled": "boolean", + "name": "string", + "description": "string", + "url": "string" + } + }, + "channels": { + "fields": { + "telegram": "object", + "discord": "object", + "slack": "object", + "whatsapp": "object" + } + } + } + })) +} + +// --------------------------------------------------------------------------- +// Config Set endpoint +// --------------------------------------------------------------------------- + +/// POST /api/config/set — Set a single config value and persist to config.toml. +/// +/// Accepts JSON `{ "path": "section.key", "value": "..." }`. +/// Writes the value to the TOML config file and triggers a reload. +pub async fn config_set( + State(state): State>, + Json(body): Json, +) -> impl IntoResponse { + let path = match body.get("path").and_then(|v| v.as_str()) { + Some(p) => p.to_string(), + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"status": "error", "error": "missing 'path' field"})), + ); + } + }; + let value = match body.get("value") { + Some(v) => v.clone(), + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"status": "error", "error": "missing 'value' field"})), + ); + } + }; + + let config_path = state.kernel.config.home_dir.join("config.toml"); + + // Read existing config as a TOML table, or start fresh + let mut table: toml::value::Table = if config_path.exists() { + match std::fs::read_to_string(&config_path) { + Ok(content) => toml::from_str(&content).unwrap_or_default(), + Err(_) => toml::value::Table::new(), + } + } else { + toml::value::Table::new() + }; + + // Convert JSON value to TOML value + let toml_val = json_to_toml_value(&value); + + // Parse "section.key" path and set value + let parts: Vec<&str> = path.split('.').collect(); + match parts.len() { + 1 => { + table.insert(parts[0].to_string(), toml_val); + } + 2 => { + let section = table + .entry(parts[0].to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())); + if let toml::Value::Table(ref mut t) = section { + t.insert(parts[1].to_string(), toml_val); + } + } + 3 => { + let section = table + .entry(parts[0].to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())); + if let toml::Value::Table(ref mut t) = section { + let sub = t + .entry(parts[1].to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())); + if let toml::Value::Table(ref mut t2) = sub { + t2.insert(parts[2].to_string(), toml_val); + } + } + } + _ => { + return ( + StatusCode::BAD_REQUEST, + Json( + serde_json::json!({"status": "error", "error": "path too deep (max 3 levels)"}), + ), + ); + } + } + + // Write back + let toml_string = match toml::to_string_pretty(&table) { + Ok(s) => s, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json( + serde_json::json!({"status": "error", "error": format!("serialize failed: {e}")}), + ), + ); + } + }; + if let Err(e) = std::fs::write(&config_path, &toml_string) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"status": "error", "error": format!("write failed: {e}")})), + ); + } + + // Trigger reload + let reload_status = match state.kernel.reload_config() { + Ok(plan) => { + if plan.restart_required { + "applied_partial" + } else { + "applied" + } + } + Err(_) => "saved_reload_failed", + }; + + state.kernel.audit_log.record( + "system", + openfang_runtime::audit::AuditAction::ConfigChange, + format!("config set: {path}"), + "completed", + ); + + ( + StatusCode::OK, + Json(serde_json::json!({"status": reload_status, "path": path})), + ) +} + +/// Convert a serde_json::Value to a toml::Value. +fn json_to_toml_value(value: &serde_json::Value) -> toml::Value { + match value { + serde_json::Value::String(s) => toml::Value::String(s.clone()), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_u64() { + toml::Value::Integer(i as i64) + } else if let Some(i) = n.as_i64() { + toml::Value::Integer(i) + } else if let Some(f) = n.as_f64() { + toml::Value::Float(f) + } else { + toml::Value::String(n.to_string()) + } + } + serde_json::Value::Bool(b) => toml::Value::Boolean(*b), + _ => toml::Value::String(value.to_string()), + } +} + +// --------------------------------------------------------------------------- +// Delivery tracking endpoints +// --------------------------------------------------------------------------- + +/// GET /api/agents/:id/deliveries — List recent delivery receipts for an agent. +pub async fn get_agent_deliveries( + State(state): State>, + Path(id): Path, + Query(params): Query>, +) -> impl IntoResponse { + let agent_id: AgentId = match id.parse() { + Ok(id) => id, + Err(_) => { + // Try name lookup + match state.kernel.registry.find_by_name(&id) { + Some(entry) => entry.id, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + ); + } + } + } + }; + + let limit = params + .get("limit") + .and_then(|v| v.parse::().ok()) + .unwrap_or(50) + .min(500); + + let receipts = state.kernel.delivery_tracker.get_receipts(agent_id, limit); + ( + StatusCode::OK, + Json(serde_json::json!({ + "agent_id": agent_id.to_string(), + "count": receipts.len(), + "receipts": receipts, + })), + ) +} + +// --------------------------------------------------------------------------- +// Cron job management endpoints +// --------------------------------------------------------------------------- + +/// GET /api/cron/jobs — List all cron jobs, optionally filtered by agent_id. +pub async fn list_cron_jobs( + State(state): State>, + Query(params): Query>, +) -> impl IntoResponse { + let jobs = if let Some(agent_id_str) = params.get("agent_id") { + match uuid::Uuid::parse_str(agent_id_str) { + Ok(uuid) => { + let aid = AgentId(uuid); + state.kernel.cron_scheduler.list_jobs(aid) + } + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid agent_id"})), + ); + } + } + } else { + state.kernel.cron_scheduler.list_all_jobs() + }; + let total = jobs.len(); + let jobs_json: Vec = jobs + .into_iter() + .map(|j| serde_json::to_value(&j).unwrap_or_default()) + .collect(); + ( + StatusCode::OK, + Json(serde_json::json!({"jobs": jobs_json, "total": total})), + ) +} + +/// POST /api/cron/jobs — Create a new cron job. +pub async fn create_cron_job( + State(state): State>, + Json(body): Json, +) -> impl IntoResponse { + let agent_id = body["agent_id"].as_str().unwrap_or(""); + match state.kernel.cron_create(agent_id, body.clone()).await { + Ok(result) => ( + StatusCode::CREATED, + Json(serde_json::json!({"result": result})), + ), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": e})), + ), + } +} + +/// DELETE /api/cron/jobs/{id} — Delete a cron job. +pub async fn delete_cron_job( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + match uuid::Uuid::parse_str(&id) { + Ok(uuid) => { + let job_id = openfang_types::scheduler::CronJobId(uuid); + match state.kernel.cron_scheduler.remove_job(job_id) { + Ok(_) => { + let _ = state.kernel.cron_scheduler.persist(); + ( + StatusCode::OK, + Json(serde_json::json!({"status": "deleted"})), + ) + } + Err(e) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } + } + Err(_) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid job ID"})), + ), + } +} + +/// PUT /api/cron/jobs/{id}/enable — Enable or disable a cron job. +pub async fn toggle_cron_job( + State(state): State>, + Path(id): Path, + Json(body): Json, +) -> impl IntoResponse { + let enabled = body["enabled"].as_bool().unwrap_or(true); + match uuid::Uuid::parse_str(&id) { + Ok(uuid) => { + let job_id = openfang_types::scheduler::CronJobId(uuid); + match state.kernel.cron_scheduler.set_enabled(job_id, enabled) { + Ok(()) => { + let _ = state.kernel.cron_scheduler.persist(); + ( + StatusCode::OK, + Json(serde_json::json!({"id": id, "enabled": enabled})), + ) + } + Err(e) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("{e}")})), + ), + } + } + Err(_) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid job ID"})), + ), + } +} + +/// GET /api/cron/jobs/{id}/status — Get status of a specific cron job. +pub async fn cron_job_status( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + match uuid::Uuid::parse_str(&id) { + Ok(uuid) => { + let job_id = openfang_types::scheduler::CronJobId(uuid); + match state.kernel.cron_scheduler.get_meta(job_id) { + Some(meta) => ( + StatusCode::OK, + Json(serde_json::to_value(&meta).unwrap_or_default()), + ), + None => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Job not found"})), + ), + } + } + Err(_) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid job ID"})), + ), + } +} + +// --------------------------------------------------------------------------- +// Webhook trigger endpoints +// --------------------------------------------------------------------------- + +/// POST /hooks/wake — Inject a system event via webhook trigger. +/// +/// Publishes a custom event through the kernel's event system, which can +/// trigger proactive agents that subscribe to the event type. +pub async fn webhook_wake( + State(state): State>, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> impl IntoResponse { + // Check if webhook triggers are enabled + let wh_config = match &state.kernel.config.webhook_triggers { + Some(c) if c.enabled => c, + _ => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Webhook triggers not enabled"})), + ); + } + }; + + // Validate bearer token (constant-time comparison) + if !validate_webhook_token(&headers, &wh_config.token_env) { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"error": "Invalid or missing token"})), + ); + } + + // Validate payload + if let Err(e) = body.validate() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": e})), + ); + } + + // Publish through the kernel's publish_event (KernelHandle trait), which + // goes through the full event processing pipeline including trigger evaluation. + let event_payload = serde_json::json!({ + "source": "webhook", + "mode": body.mode, + "text": body.text, + }); + if let Err(e) = + KernelHandle::publish_event(state.kernel.as_ref(), "webhook.wake", event_payload).await + { + tracing::warn!("Webhook wake event publish failed: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Event publish failed: {e}")})), + ); + } + + ( + StatusCode::OK, + Json(serde_json::json!({"status": "accepted", "mode": body.mode})), + ) +} + +/// POST /hooks/agent — Run an isolated agent turn via webhook. +/// +/// Sends a message directly to the specified agent and returns the response. +/// This enables external systems (CI/CD, Slack, etc.) to trigger agent work. +pub async fn webhook_agent( + State(state): State>, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> impl IntoResponse { + // Check if webhook triggers are enabled + let wh_config = match &state.kernel.config.webhook_triggers { + Some(c) if c.enabled => c, + _ => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Webhook triggers not enabled"})), + ); + } + }; + + // Validate bearer token + if !validate_webhook_token(&headers, &wh_config.token_env) { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"error": "Invalid or missing token"})), + ); + } + + // Validate payload + if let Err(e) = body.validate() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": e})), + ); + } + + // Resolve the agent by name or ID (if not specified, use the first running agent) + let agent_id: AgentId = match &body.agent { + Some(agent_ref) => match agent_ref.parse() { + Ok(id) => id, + Err(_) => { + // Try name lookup + match state.kernel.registry.find_by_name(agent_ref) { + Some(entry) => entry.id, + None => { + return ( + StatusCode::NOT_FOUND, + Json( + serde_json::json!({"error": format!("Agent not found: {}", agent_ref)}), + ), + ); + } + } + } + }, + None => { + // No agent specified — use the first available agent + match state.kernel.registry.list().first() { + Some(entry) => entry.id, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "No agents available"})), + ); + } + } + } + }; + + // Actually send the message to the agent and get the response + match state.kernel.send_message(agent_id, &body.message).await { + Ok(result) => ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "completed", + "agent_id": agent_id.to_string(), + "response": result.response, + "usage": { + "input_tokens": result.total_usage.input_tokens, + "output_tokens": result.total_usage.output_tokens, + }, + })), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Agent execution failed: {e}")})), + ), + } +} + +// ─── Agent Bindings API ──────────────────────────────────────────────── + +/// GET /api/bindings — List all agent bindings. +pub async fn list_bindings(State(state): State>) -> impl IntoResponse { + let bindings = state.kernel.list_bindings(); + ( + StatusCode::OK, + Json(serde_json::json!({ "bindings": bindings })), + ) +} + +/// POST /api/bindings — Add a new agent binding. +pub async fn add_binding( + State(state): State>, + Json(binding): Json, +) -> impl IntoResponse { + // Validate agent exists + let agents = state.kernel.registry.list(); + let agent_exists = agents.iter().any(|e| e.name == binding.agent) + || binding.agent.parse::().is_ok(); + if !agent_exists { + tracing::warn!(agent = %binding.agent, "Binding references unknown agent"); + } + + state.kernel.add_binding(binding); + ( + StatusCode::CREATED, + Json(serde_json::json!({ "status": "created" })), + ) +} + +/// DELETE /api/bindings/:index — Remove a binding by index. +pub async fn remove_binding( + State(state): State>, + Path(index): Path, +) -> impl IntoResponse { + match state.kernel.remove_binding(index) { + Some(_) => ( + StatusCode::OK, + Json(serde_json::json!({ "status": "removed" })), + ), + None => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Binding index out of range" })), + ), + } +} + +// ─── Device Pairing endpoints ─────────────────────────────────────────── + +/// POST /api/pairing/request — Create a new pairing request (returns token + QR URI). +pub async fn pairing_request(State(state): State>) -> impl IntoResponse { + if !state.kernel.config.pairing.enabled { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Pairing not enabled"})), + ) + .into_response(); + } + match state.kernel.pairing.create_pairing_request() { + Ok(req) => { + let qr_uri = format!("openfang://pair?token={}", req.token); + Json(serde_json::json!({ + "token": req.token, + "qr_uri": qr_uri, + "expires_at": req.expires_at.to_rfc3339(), + })) + .into_response() + } + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": e})), + ) + .into_response(), + } +} + +/// POST /api/pairing/complete — Complete pairing with token + device info. +pub async fn pairing_complete( + State(state): State>, + Json(body): Json, +) -> impl IntoResponse { + if !state.kernel.config.pairing.enabled { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Pairing not enabled"})), + ) + .into_response(); + } + let token = body.get("token").and_then(|v| v.as_str()).unwrap_or(""); + let display_name = body + .get("display_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let platform = body + .get("platform") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let push_token = body + .get("push_token") + .and_then(|v| v.as_str()) + .map(String::from); + let device_info = openfang_kernel::pairing::PairedDevice { + device_id: uuid::Uuid::new_v4().to_string(), + display_name: display_name.to_string(), + platform: platform.to_string(), + paired_at: chrono::Utc::now(), + last_seen: chrono::Utc::now(), + push_token, + }; + match state.kernel.pairing.complete_pairing(token, device_info) { + Ok(device) => Json(serde_json::json!({ + "device_id": device.device_id, + "display_name": device.display_name, + "platform": device.platform, + "paired_at": device.paired_at.to_rfc3339(), + })) + .into_response(), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": e})), + ) + .into_response(), + } +} + +/// GET /api/pairing/devices — List paired devices. +pub async fn pairing_devices(State(state): State>) -> impl IntoResponse { + if !state.kernel.config.pairing.enabled { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Pairing not enabled"})), + ) + .into_response(); + } + let devices: Vec<_> = state + .kernel + .pairing + .list_devices() + .into_iter() + .map(|d| { + serde_json::json!({ + "device_id": d.device_id, + "display_name": d.display_name, + "platform": d.platform, + "paired_at": d.paired_at.to_rfc3339(), + "last_seen": d.last_seen.to_rfc3339(), + }) + }) + .collect(); + Json(serde_json::json!({"devices": devices})).into_response() +} + +/// DELETE /api/pairing/devices/{id} — Remove a paired device. +pub async fn pairing_remove_device( + State(state): State>, + Path(device_id): Path, +) -> impl IntoResponse { + if !state.kernel.config.pairing.enabled { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Pairing not enabled"})), + ) + .into_response(); + } + match state.kernel.pairing.remove_device(&device_id) { + Ok(()) => Json(serde_json::json!({"ok": true})).into_response(), + Err(e) => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": e}))).into_response(), + } +} + +/// POST /api/pairing/notify — Push a notification to all paired devices. +pub async fn pairing_notify( + State(state): State>, + Json(body): Json, +) -> impl IntoResponse { + if !state.kernel.config.pairing.enabled { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Pairing not enabled"})), + ) + .into_response(); + } + let title = body + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("OpenFang"); + let message = body.get("message").and_then(|v| v.as_str()).unwrap_or(""); + if message.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "message is required"})), + ) + .into_response(); + } + state.kernel.pairing.notify_devices(title, message).await; + Json(serde_json::json!({"ok": true, "notified": state.kernel.pairing.list_devices().len()})) + .into_response() +} + +/// GET /api/commands — List available chat commands (for dynamic slash menu). +pub async fn list_commands(State(state): State>) -> impl IntoResponse { + let mut commands = vec![ + serde_json::json!({"cmd": "/help", "desc": "Show available commands"}), + serde_json::json!({"cmd": "/new", "desc": "Reset session (clear history)"}), + serde_json::json!({"cmd": "/compact", "desc": "Trigger LLM session compaction"}), + serde_json::json!({"cmd": "/model", "desc": "Show or switch model (/model [name])"}), + serde_json::json!({"cmd": "/stop", "desc": "Cancel current agent run"}), + serde_json::json!({"cmd": "/usage", "desc": "Show session token usage & cost"}), + serde_json::json!({"cmd": "/think", "desc": "Toggle extended thinking (/think [on|off|stream])"}), + serde_json::json!({"cmd": "/context", "desc": "Show context window usage & pressure"}), + serde_json::json!({"cmd": "/verbose", "desc": "Cycle tool detail level (/verbose [off|on|full])"}), + serde_json::json!({"cmd": "/queue", "desc": "Check if agent is processing"}), + serde_json::json!({"cmd": "/status", "desc": "Show system status"}), + serde_json::json!({"cmd": "/clear", "desc": "Clear chat display"}), + serde_json::json!({"cmd": "/exit", "desc": "Disconnect from agent"}), + ]; + + // Add skill-registered tool names as potential commands + if let Ok(registry) = state.kernel.skill_registry.read() { + for skill in registry.list() { + let desc: String = skill.manifest.skill.description.chars().take(80).collect(); + commands.push(serde_json::json!({ + "cmd": format!("/{}", skill.manifest.skill.name), + "desc": if desc.is_empty() { format!("Skill: {}", skill.manifest.skill.name) } else { desc }, + "source": "skill", + })); + } + } + + Json(serde_json::json!({"commands": commands})) +} + +/// SECURITY: Validate webhook bearer token using constant-time comparison. +fn validate_webhook_token(headers: &axum::http::HeaderMap, token_env: &str) -> bool { + let expected = match std::env::var(token_env) { + Ok(t) if t.len() >= 32 => t, + _ => return false, + }; + + let provided = match headers.get("authorization") { + Some(v) => match v.to_str() { + Ok(s) if s.starts_with("Bearer ") => &s[7..], + _ => return false, + }, + None => return false, + }; + + use subtle::ConstantTimeEq; + if provided.len() != expected.len() { + return false; + } + provided.as_bytes().ct_eq(expected.as_bytes()).into() +} + +// ══════════════════════════════════════════════════════════════════════ +// GitHub Copilot OAuth Device Flow +// ══════════════════════════════════════════════════════════════════════ + +/// State for an in-progress device flow. +struct CopilotFlowState { + device_code: String, + interval: u64, + expires_at: Instant, +} + +/// Active device flows, keyed by poll_id. Auto-expire after the flow's TTL. +static COPILOT_FLOWS: LazyLock> = LazyLock::new(DashMap::new); + +/// POST /api/providers/github-copilot/oauth/start +/// +/// Initiates a GitHub device flow for Copilot authentication. +/// Returns a user code and verification URI that the user visits in their browser. +pub async fn copilot_oauth_start() -> impl IntoResponse { + // Clean up expired flows first + COPILOT_FLOWS.retain(|_, state| state.expires_at > Instant::now()); + + match openfang_runtime::copilot_oauth::start_device_flow().await { + Ok(resp) => { + let poll_id = uuid::Uuid::new_v4().to_string(); + + COPILOT_FLOWS.insert( + poll_id.clone(), + CopilotFlowState { + device_code: resp.device_code, + interval: resp.interval, + expires_at: Instant::now() + std::time::Duration::from_secs(resp.expires_in), + }, + ); + + ( + StatusCode::OK, + Json(serde_json::json!({ + "user_code": resp.user_code, + "verification_uri": resp.verification_uri, + "poll_id": poll_id, + "expires_in": resp.expires_in, + "interval": resp.interval, + })), + ) + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + ), + } +} + +/// GET /api/providers/github-copilot/oauth/poll/{poll_id} +/// +/// Poll the status of a GitHub device flow. +/// Returns `pending`, `complete`, `expired`, `denied`, or `error`. +/// On `complete`, saves the token to secrets.env and sets GITHUB_TOKEN. +pub async fn copilot_oauth_poll( + State(state): State>, + Path(poll_id): Path, +) -> impl IntoResponse { + let flow = match COPILOT_FLOWS.get(&poll_id) { + Some(f) => f, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"status": "not_found", "error": "Unknown poll_id"})), + ) + } + }; + + if flow.expires_at <= Instant::now() { + drop(flow); + COPILOT_FLOWS.remove(&poll_id); + return ( + StatusCode::OK, + Json(serde_json::json!({"status": "expired"})), + ); + } + + let device_code = flow.device_code.clone(); + drop(flow); + + match openfang_runtime::copilot_oauth::poll_device_flow(&device_code).await { + openfang_runtime::copilot_oauth::DeviceFlowStatus::Pending => ( + StatusCode::OK, + Json(serde_json::json!({"status": "pending"})), + ), + openfang_runtime::copilot_oauth::DeviceFlowStatus::Complete { access_token } => { + // Save to secrets.env + let secrets_path = state.kernel.config.home_dir.join("secrets.env"); + if let Err(e) = write_secret_env(&secrets_path, "GITHUB_TOKEN", &access_token) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json( + serde_json::json!({"status": "error", "error": format!("Failed to save token: {e}")}), + ), + ); + } + + // Set in current process + std::env::set_var("GITHUB_TOKEN", access_token.as_str()); + + // Refresh auth detection + state + .kernel + .model_catalog + .write() + .unwrap_or_else(|e| e.into_inner()) + .detect_auth(); + + // Clean up flow state + COPILOT_FLOWS.remove(&poll_id); + + ( + StatusCode::OK, + Json(serde_json::json!({"status": "complete"})), + ) + } + openfang_runtime::copilot_oauth::DeviceFlowStatus::SlowDown { new_interval } => { + // Update interval + if let Some(mut f) = COPILOT_FLOWS.get_mut(&poll_id) { + f.interval = new_interval; + } + ( + StatusCode::OK, + Json(serde_json::json!({"status": "pending", "interval": new_interval})), + ) + } + openfang_runtime::copilot_oauth::DeviceFlowStatus::Expired => { + COPILOT_FLOWS.remove(&poll_id); + ( + StatusCode::OK, + Json(serde_json::json!({"status": "expired"})), + ) + } + openfang_runtime::copilot_oauth::DeviceFlowStatus::AccessDenied => { + COPILOT_FLOWS.remove(&poll_id); + ( + StatusCode::OK, + Json(serde_json::json!({"status": "denied"})), + ) + } + openfang_runtime::copilot_oauth::DeviceFlowStatus::Error(e) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "error", "error": e})), + ), + } +} + +// --------------------------------------------------------------------------- +// Agent Communication (Comms) endpoints +// --------------------------------------------------------------------------- + +/// GET /api/comms/topology — Build agent topology graph from registry. +pub async fn comms_topology(State(state): State>) -> impl IntoResponse { + use openfang_types::comms::{EdgeKind, TopoEdge, TopoNode, Topology}; + + let agents = state.kernel.registry.list(); + + let nodes: Vec = agents + .iter() + .map(|e| TopoNode { + id: e.id.to_string(), + name: e.name.clone(), + state: format!("{:?}", e.state), + model: e.manifest.model.model.clone(), + }) + .collect(); + + let mut edges: Vec = Vec::new(); + + // Parent-child edges from registry + for agent in &agents { + for child_id in &agent.children { + edges.push(TopoEdge { + from: agent.id.to_string(), + to: child_id.to_string(), + kind: EdgeKind::ParentChild, + }); + } + } + + // Peer message edges from event bus history + let events = state.kernel.event_bus.history(500).await; + let mut peer_pairs = std::collections::HashSet::new(); + for event in &events { + if let openfang_types::event::EventPayload::Message(_) = &event.payload { + if let openfang_types::event::EventTarget::Agent(target_id) = &event.target { + let from = event.source.to_string(); + let to = target_id.to_string(); + // Deduplicate: only one edge per pair, skip self-loops + if from != to { + let key = if from < to { + (from.clone(), to.clone()) + } else { + (to.clone(), from.clone()) + }; + if peer_pairs.insert(key) { + edges.push(TopoEdge { + from, + to, + kind: EdgeKind::Peer, + }); + } + } + } + } + } + + Json(serde_json::to_value(Topology { nodes, edges }).unwrap_or_default()) +} + +/// Filter a kernel event into a CommsEvent, if it represents inter-agent communication. +fn filter_to_comms_event( + event: &openfang_types::event::Event, + agents: &[openfang_types::agent::AgentEntry], +) -> Option { + use openfang_types::comms::{CommsEvent, CommsEventKind}; + use openfang_types::event::{EventPayload, EventTarget, LifecycleEvent}; + + let resolve_name = |id: &str| -> String { + agents + .iter() + .find(|a| a.id.to_string() == id) + .map(|a| a.name.clone()) + .unwrap_or_else(|| id.to_string()) + }; + + match &event.payload { + EventPayload::Message(msg) => { + let target_id = match &event.target { + EventTarget::Agent(id) => id.to_string(), + _ => String::new(), + }; + Some(CommsEvent { + id: event.id.to_string(), + timestamp: event.timestamp.to_rfc3339(), + kind: CommsEventKind::AgentMessage, + source_id: event.source.to_string(), + source_name: resolve_name(&event.source.to_string()), + target_id: target_id.clone(), + target_name: resolve_name(&target_id), + detail: openfang_types::truncate_str(&msg.content, 200).to_string(), + }) + } + EventPayload::Lifecycle(lifecycle) => match lifecycle { + LifecycleEvent::Spawned { agent_id, name } => Some(CommsEvent { + id: event.id.to_string(), + timestamp: event.timestamp.to_rfc3339(), + kind: CommsEventKind::AgentSpawned, + source_id: event.source.to_string(), + source_name: resolve_name(&event.source.to_string()), + target_id: agent_id.to_string(), + target_name: name.clone(), + detail: format!("Agent '{}' spawned", name), + }), + LifecycleEvent::Terminated { agent_id, reason } => Some(CommsEvent { + id: event.id.to_string(), + timestamp: event.timestamp.to_rfc3339(), + kind: CommsEventKind::AgentTerminated, + source_id: event.source.to_string(), + source_name: resolve_name(&event.source.to_string()), + target_id: agent_id.to_string(), + target_name: resolve_name(&agent_id.to_string()), + detail: format!("Terminated: {}", reason), + }), + _ => None, + }, + _ => None, + } +} + +/// Convert an audit entry into a CommsEvent if it represents inter-agent activity. +fn audit_to_comms_event( + entry: &openfang_runtime::audit::AuditEntry, + agents: &[openfang_types::agent::AgentEntry], +) -> Option { + use openfang_types::comms::{CommsEvent, CommsEventKind}; + + let resolve_name = |id: &str| -> String { + agents + .iter() + .find(|a| a.id.to_string() == id) + .map(|a| a.name.clone()) + .unwrap_or_else(|| { + if id.is_empty() || id == "system" { + "system".to_string() + } else { + openfang_types::truncate_str(id, 12).to_string() + } + }) + }; + + let action_str = format!("{:?}", entry.action); + let (kind, detail, target_label) = match action_str.as_str() { + "AgentMessage" => { + // Format detail: "tokens_in=X, tokens_out=Y" → readable summary + let detail = if entry.detail.starts_with("tokens_in=") { + let parts: Vec<&str> = entry.detail.split(", ").collect(); + let in_tok = parts + .first() + .and_then(|p| p.strip_prefix("tokens_in=")) + .unwrap_or("?"); + let out_tok = parts + .get(1) + .and_then(|p| p.strip_prefix("tokens_out=")) + .unwrap_or("?"); + if entry.outcome == "ok" { + format!("{} in / {} out tokens", in_tok, out_tok) + } else { + format!( + "{} in / {} out — {}", + in_tok, + out_tok, + openfang_types::truncate_str(&entry.outcome, 80) + ) + } + } else if entry.outcome != "ok" { + format!( + "{} — {}", + openfang_types::truncate_str(&entry.detail, 80), + openfang_types::truncate_str(&entry.outcome, 80) + ) + } else { + openfang_types::truncate_str(&entry.detail, 200).to_string() + }; + (CommsEventKind::AgentMessage, detail, "user") + } + "AgentSpawn" => ( + CommsEventKind::AgentSpawned, + format!( + "Agent spawned: {}", + openfang_types::truncate_str(&entry.detail, 100) + ), + "", + ), + "AgentKill" => ( + CommsEventKind::AgentTerminated, + format!( + "Agent killed: {}", + openfang_types::truncate_str(&entry.detail, 100) + ), + "", + ), + _ => return None, + }; + + Some(CommsEvent { + id: format!("audit-{}", entry.seq), + timestamp: entry.timestamp.clone(), + kind, + source_id: entry.agent_id.clone(), + source_name: resolve_name(&entry.agent_id), + target_id: if target_label.is_empty() { + String::new() + } else { + target_label.to_string() + }, + target_name: if target_label.is_empty() { + String::new() + } else { + target_label.to_string() + }, + detail, + }) +} + +/// GET /api/comms/events — Return recent inter-agent communication events. +/// +/// Sources from both the event bus (for lifecycle events with full context) +/// and the audit log (for message/spawn/kill events that are always captured). +pub async fn comms_events( + State(state): State>, + Query(params): Query>, +) -> impl IntoResponse { + let limit = params + .get("limit") + .and_then(|v| v.parse::().ok()) + .unwrap_or(100) + .min(500); + + let agents = state.kernel.registry.list(); + + // Primary source: event bus (has full source/target context) + let bus_events = state.kernel.event_bus.history(500).await; + let mut comms_events: Vec = bus_events + .iter() + .filter_map(|e| filter_to_comms_event(e, &agents)) + .collect(); + + // Secondary source: audit log (always populated, wider coverage) + let audit_entries = state.kernel.audit_log.recent(500); + let seen_ids: std::collections::HashSet = + comms_events.iter().map(|e| e.id.clone()).collect(); + + for entry in audit_entries.iter().rev() { + if let Some(ev) = audit_to_comms_event(entry, &agents) { + if !seen_ids.contains(&ev.id) { + comms_events.push(ev); + } + } + } + + // Sort by timestamp descending (newest first) + comms_events.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + comms_events.truncate(limit); + + Json(comms_events) +} + +/// GET /api/comms/events/stream — SSE stream of inter-agent communication events. +/// +/// Polls the audit log every 500ms for new inter-agent events. +pub async fn comms_events_stream(State(state): State>) -> axum::response::Response { + use axum::response::sse::{Event, KeepAlive, Sse}; + + let (tx, rx) = tokio::sync::mpsc::channel::< + Result, + >(256); + + tokio::spawn(async move { + let mut last_seq: u64 = { + let entries = state.kernel.audit_log.recent(1); + entries.last().map(|e| e.seq).unwrap_or(0) + }; + + loop { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + let agents = state.kernel.registry.list(); + let entries = state.kernel.audit_log.recent(50); + + for entry in &entries { + if entry.seq <= last_seq { + continue; + } + if let Some(comms_event) = audit_to_comms_event(entry, &agents) { + let data = serde_json::to_string(&comms_event).unwrap_or_default(); + if tx.send(Ok(Event::default().data(data))).await.is_err() { + return; // Client disconnected + } + } + } + + if let Some(last) = entries.last() { + last_seq = last.seq; + } + } + }); + + let rx_stream = tokio_stream::wrappers::ReceiverStream::new(rx); + Sse::new(rx_stream) + .keep_alive( + KeepAlive::new() + .interval(std::time::Duration::from_secs(15)) + .text("ping"), + ) + .into_response() +} + +/// POST /api/comms/send — Send a message from one agent to another. +pub async fn comms_send( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + // Validate from agent exists + let from_id: openfang_types::agent::AgentId = match req.from_agent_id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid from_agent_id"})), + ) + } + }; + if state.kernel.registry.get(from_id).is_none() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Source agent not found"})), + ); + } + + // Validate to agent exists + let to_id: openfang_types::agent::AgentId = match req.to_agent_id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid to_agent_id"})), + ) + } + }; + if state.kernel.registry.get(to_id).is_none() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Target agent not found"})), + ); + } + + // SECURITY: Limit message size + if req.message.len() > 64 * 1024 { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + Json(serde_json::json!({"error": "Message too large (max 64KB)"})), + ); + } + + match state.kernel.send_message(to_id, &req.message).await { + Ok(result) => ( + StatusCode::OK, + Json(serde_json::json!({ + "ok": true, + "response": result.response, + "input_tokens": result.total_usage.input_tokens, + "output_tokens": result.total_usage.output_tokens, + })), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Message delivery failed: {e}")})), + ), + } +} + +/// POST /api/comms/task — Post a task to the agent task queue. +pub async fn comms_task( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + if req.title.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Title is required"})), + ); + } + + match state + .kernel + .memory + .task_post( + &req.title, + &req.description, + req.assigned_to.as_deref(), + Some("ui-user"), + ) + .await + { + Ok(task_id) => ( + StatusCode::CREATED, + Json(serde_json::json!({ + "ok": true, + "task_id": task_id, + })), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Failed to post task: {e}")})), + ), + } +} + +fn serialize_session_messages( + messages: &[openfang_types::message::Message], +) -> Vec { + use std::collections::HashMap; + let mut global_tool_map: HashMap = HashMap::new(); + + for m in messages { + if let openfang_types::message::MessageContent::Blocks(blocks) = &m.content { + for b in blocks { + match b { + openfang_types::message::ContentBlock::ToolUse { + id, name, input, .. + } => { + global_tool_map.insert( + id.clone(), + serde_json::json!({ + "id": id, + "name": name, + "input": input, + }), + ); + } + openfang_types::message::ContentBlock::ToolResult { + tool_use_id, + tool_name, + content: result, + is_error, + .. + } => { + if let Some(tool) = global_tool_map.get_mut(tool_use_id) { + tool["name"] = serde_json::Value::String(tool_name.clone()); + tool["result"] = serde_json::Value::String(result.clone()); + tool["is_error"] = serde_json::Value::Bool(*is_error); + } else { + global_tool_map.insert( + tool_use_id.clone(), + serde_json::json!({ + "id": tool_use_id, + "name": tool_name, + "result": result, + "is_error": is_error, + }), + ); + } + } + _ => {} + } + } + } + } + + messages + .iter() + .filter_map(|m| { + let content = match &m.content { + openfang_types::message::MessageContent::Text(t) => t.clone(), + openfang_types::message::MessageContent::Blocks(blocks) => { + let mut texts = Vec::new(); + let mut msg_tools = Vec::new(); + + for b in blocks { + match b { + openfang_types::message::ContentBlock::Text { text } => { + texts.push(text.clone()); + } + openfang_types::message::ContentBlock::Image { .. } => { + texts.push("[Image]".to_string()); + } + openfang_types::message::ContentBlock::ToolUse { id, .. } => { + if m.role == openfang_types::message::Role::Assistant { + if let Some(tool) = global_tool_map.get(id) { + msg_tools.push(tool.clone()); + } + } + } + openfang_types::message::ContentBlock::ToolResult { .. } => {} + _ => {} + } + } + + if texts.is_empty() && msg_tools.is_empty() { + return None; + } + + let role = if !msg_tools.is_empty() { + "Assistant" + } else { + &format!("{:?}", m.role) + }; + + let mut msg = serde_json::json!({ + "role": role, + "content": texts.join("\n"), + }); + if !msg_tools.is_empty() { + msg["tools"] = serde_json::Value::Array(msg_tools); + } + return Some(msg); + } + }; + + if content.is_empty() { + return None; + } + + Some(serde_json::json!({ + "role": format!("{:?}", m.role), + "content": content, + })) + }) + .collect() +} diff --git a/crates/openfang-api/static/js/pages/chat.js b/crates/openfang-api/static/js/pages/chat.js index f1c96d45b..163919f98 100644 --- a/crates/openfang-api/static/js/pages/chat.js +++ b/crates/openfang-api/static/js/pages/chat.js @@ -1082,6 +1082,7 @@ function chatPage() { formatToolJson: function(text) { if (!text) return ''; + if (text instanceof Object) return JSON.stringify(text); try { return JSON.stringify(JSON.parse(text), null, 2); } catch(e) { return text; } },