Files
openfang/crates/openfang-api/src/routes.rs
T
Evan HuandClaude Opus 4.6 77ed954d18 wecom channel adapter
* feat: Add WeCom (WeChat Work) channel adapter

- Add wecom.rs channel adapter implementation
- Add WeComConfig in config.rs
- Register WeCom adapter in channel_bridge.rs

WeCom channel supports:
- Inbound messages via callback webhook
- Outbound messages via WeCom API
- Access token caching and auto-refresh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: handle WeCom callbacks and preserve hand extension tools

* fix: render WeCom replies as plain text

* fix: resolve clippy warnings in wecom adapter

- Remove unused WECOM_API_HOST constant
- Fix needless borrow in send_text call
- Replace assert_eq!(bool, true) with assert!()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: cargo fmt for wecom-related files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: cargo fmt --all

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: upgrade quinn-proto and add cargo audit ignore list

- Upgrade quinn-proto 0.11.13 → 0.11.14 (RUSTSEC-2026-0037 DoS fix)
- Add .cargo/audit.toml to ignore unmaintainable transitive deps
  (tauri GTK3 bindings, time pinned by mac-notification-sys, etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:50:43 +03:00

11318 lines
418 KiB
Rust

//! 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<OpenFangKernel>,
pub started_at: Instant,
/// Optional peer registry for OFP mesh networking status.
pub peer_registry: Option<Arc<openfang_wire::registry::PeerRegistry>>,
/// Channel bridge manager — held behind a Mutex so it can be swapped on hot-reload.
pub bridge_manager: tokio::sync::Mutex<Option<openfang_channels::bridge::BridgeManager>>,
/// Live channel config — updated on every hot-reload so list_channels() reflects reality.
pub channels_config: tokio::sync::RwLock<openfang_types::config::ChannelsConfig>,
/// Notify handle to trigger graceful HTTP server shutdown from the API.
pub shutdown_notify: Arc<tokio::sync::Notify>,
/// 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<String, (Instant, serde_json::Value)>,
/// Probe cache for local provider health checks (ollama/vllm/lmstudio).
/// Avoids blocking the `/api/providers` endpoint on TCP timeouts to
/// unreachable local services. 60-second TTL.
pub provider_probe_cache: openfang_runtime::provider_health::ProbeCache,
}
/// POST /api/agents — Spawn a new agent.
pub async fn spawn_agent(
State(state): State<Arc<AppState>>,
Json(req): Json<SpawnRequest>,
) -> impl IntoResponse {
// Resolve template name → manifest_toml if template is provided and manifest_toml is empty
let manifest_toml = if req.manifest_toml.trim().is_empty() {
if let Some(ref tmpl_name) = req.template {
// Sanitize template name to prevent path traversal
let safe_name = tmpl_name
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.collect::<String>();
if safe_name.is_empty() || safe_name != *tmpl_name {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid template name"})),
);
}
let tmpl_path = state
.kernel
.config
.home_dir
.join("agents")
.join(&safe_name)
.join("agent.toml");
match std::fs::read_to_string(&tmpl_path) {
Ok(content) => content,
Err(_) => {
return (
StatusCode::NOT_FOUND,
Json(
serde_json::json!({"error": format!("Template '{}' not found", safe_name)}),
),
);
}
}
} else {
return (
StatusCode::BAD_REQUEST,
Json(
serde_json::json!({"error": "Either 'manifest_toml' or 'template' is required"}),
),
);
}
} else {
req.manifest_toml.clone()
};
// SECURITY: Reject oversized manifests to prevent parser memory exhaustion.
const MAX_MANIFEST_SIZE: usize = 1024 * 1024; // 1MB
if 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() != 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(&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) => {
// Register in channel router so binding resolution finds the new agent
if let Some(ref mgr) = *state.bridge_manager.lock().await {
mgr.router().register_agent(name.clone(), 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<Arc<AppState>>) -> 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<serde_json::Value> = 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<openfang_types::message::ContentBlock> {
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<openfang_types::message::ContentBlock>,
) {
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<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<MessageRequest>,
) -> 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)"})),
);
}
// Check agent exists before processing
if state.kernel.registry.get(agent_id).is_none() {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Agent not found"})),
);
}
// 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<dyn KernelHandle> = state.kernel.clone() as Arc<dyn KernelHandle>;
match state
.kernel
.send_message_with_handle(
agent_id,
&req.message,
Some(kernel_handle),
req.sender_id,
req.sender_name,
)
.await
{
Ok(result) => {
// Strip <think>...</think> blocks from model output
let cleaned = crate::ws::strip_think_tags(&result.response);
// If the agent intentionally returned a silent/NO_REPLY response,
// return an empty string — don't generate debug fallback text.
let response = if result.silent {
String::new()
} else if cleaned.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 {
cleaned
};
(
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}");
let status = if format!("{e}").contains("Agent not found") {
StatusCode::NOT_FOUND
} else if format!("{e}").contains("quota") || format!("{e}").contains("Quota") {
StatusCode::TOO_MANY_REQUESTS
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
(
status,
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<Arc<AppState>>,
Path(id): Path<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 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)) => {
// Two-pass approach: ToolUse blocks live in Assistant messages while
// ToolResult blocks arrive in subsequent User messages. Pass 1
// collects all tool_use entries keyed by id; pass 2 attaches results.
// Pass 1: build messages and a lookup from tool_use_id → (msg_idx, tool_idx)
use base64::Engine as _;
let mut built_messages: Vec<serde_json::Value> = Vec::new();
let mut tool_use_index: std::collections::HashMap<String, (usize, usize)> =
std::collections::HashMap::new();
for m in &session.messages {
let mut tools: Vec<serde_json::Value> = Vec::new();
let mut msg_images: Vec<serde_json::Value> = Vec::new();
let content = match &m.content {
openfang_types::message::MessageContent::Text(t) => t.clone(),
openfang_types::message::MessageContent::Blocks(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 {
media_type,
data,
} => {
texts.push("[Image]".to_string());
// Persist image to upload dir so it can be
// served back when loading session history.
let file_id = uuid::Uuid::new_v4().to_string();
let upload_dir = std::env::temp_dir().join("openfang_uploads");
let _ = std::fs::create_dir_all(&upload_dir);
if let Ok(bytes) =
base64::engine::general_purpose::STANDARD.decode(data)
{
let _ = std::fs::write(upload_dir.join(&file_id), &bytes);
UPLOAD_REGISTRY.insert(
file_id.clone(),
UploadMeta {
filename: format!(
"image.{}",
media_type.rsplit('/').next().unwrap_or("png")
),
content_type: media_type.clone(),
},
);
msg_images.push(serde_json::json!({
"file_id": file_id,
"filename": format!("image.{}", media_type.rsplit('/').next().unwrap_or("png")),
}));
}
}
openfang_types::message::ContentBlock::ToolUse {
id,
name,
input,
..
} => {
let tool_idx = tools.len();
tools.push(serde_json::json!({
"name": name,
"input": input,
"running": false,
"expanded": false,
}));
// Will be filled after this loop when we know msg_idx
tool_use_index.insert(id.clone(), (usize::MAX, tool_idx));
}
// ToolResult blocks are handled in pass 2
openfang_types::message::ContentBlock::ToolResult { .. } => {}
_ => {}
}
}
texts.join("\n")
}
};
// Skip messages that are purely tool results (User role with only ToolResult blocks)
if content.is_empty() && tools.is_empty() {
continue;
}
let msg_idx = built_messages.len();
// Fix up the msg_idx for tool_use entries registered with sentinel
for (_, (mi, _)) in tool_use_index.iter_mut() {
if *mi == usize::MAX {
*mi = msg_idx;
}
}
let mut msg = serde_json::json!({
"role": format!("{:?}", m.role),
"content": content,
});
if !tools.is_empty() {
msg["tools"] = serde_json::Value::Array(tools);
}
if !msg_images.is_empty() {
msg["images"] = serde_json::Value::Array(msg_images);
}
built_messages.push(msg);
}
// Pass 2: walk messages again and attach ToolResult to the correct tool
for m in &session.messages {
if let openfang_types::message::MessageContent::Blocks(blocks) = &m.content {
for b in blocks {
if let openfang_types::message::ContentBlock::ToolResult {
tool_use_id,
content: result,
is_error,
..
} = b
{
if let Some(&(msg_idx, tool_idx)) = tool_use_index.get(tool_use_id) {
if let Some(msg) = built_messages.get_mut(msg_idx) {
if let Some(tools_arr) =
msg.get_mut("tools").and_then(|v| v.as_array_mut())
{
if let Some(tool_obj) = tools_arr.get_mut(tool_idx) {
let preview: String =
result.chars().take(2000).collect();
tool_obj["result"] = serde_json::Value::String(preview);
tool_obj["is_error"] =
serde_json::Value::Bool(*is_error);
}
}
}
}
}
}
}
}
let messages = built_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<Arc<AppState>>,
Path(id): Path<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"})),
);
}
};
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"})),
)
}
}
}
/// POST /api/agents/{id}/restart — Restart a crashed/stuck agent.
///
/// Cancels any active task, resets agent state to Running, and updates last_active.
/// Returns the agent's new state.
pub async fn restart_agent(
State(state): State<Arc<AppState>>,
Path(id): Path<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"})),
);
}
};
// Check agent exists
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 agent_name = entry.name.clone();
let previous_state = format!("{:?}", entry.state);
drop(entry);
// Cancel any running task
let was_running = state.kernel.stop_agent_run(agent_id).unwrap_or(false);
// Reset state to Running (also updates last_active)
let _ = state
.kernel
.registry
.set_state(agent_id, openfang_types::agent::AgentState::Running);
tracing::info!(
agent = %agent_name,
previous_state = %previous_state,
task_cancelled = was_running,
"Agent restarted via API"
);
(
StatusCode::OK,
Json(serde_json::json!({
"status": "restarted",
"agent": agent_name,
"agent_id": id,
"previous_state": previous_state,
"task_cancelled": was_running,
})),
)
}
/// GET /api/status — Kernel status.
pub async fn status(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let agents: Vec<serde_json::Value> = 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",
"version": env!("CARGO_PKG_VERSION"),
"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<Arc<AppState>>) -> 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<Arc<AppState>>,
Json(req): Json<serde_json::Value>,
) -> 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<Arc<AppState>>) -> impl IntoResponse {
let workflows = state.kernel.workflows.list_workflows().await;
let list: Vec<serde_json::Value> = 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<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<serde_json::Value>,
) -> 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<Arc<AppState>>,
Path(_id): Path<String>,
) -> impl IntoResponse {
let runs = state.kernel.workflows.list_runs(None).await;
let list: Vec<serde_json::Value> = 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)
}
/// GET /api/workflows/:id — Get a single workflow by ID.
pub async fn get_workflow(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> 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"})),
);
}
});
match state.kernel.workflows.get_workflow(workflow_id).await {
Some(w) => (
StatusCode::OK,
Json(serde_json::json!({
"id": w.id.to_string(),
"name": w.name,
"description": w.description,
"steps": w.steps,
"created_at": w.created_at.to_rfc3339(),
})),
),
None => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Workflow not found"})),
),
}
}
/// PUT /api/workflows/:id — Update a workflow definition.
pub async fn update_workflow(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<serde_json::Value>,
) -> 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 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 updated = Workflow {
id: workflow_id,
name,
description,
steps,
created_at: chrono::Utc::now(), // preserved by engine
};
if state
.kernel
.workflows
.update_workflow(workflow_id, updated)
.await
{
(
StatusCode::OK,
Json(serde_json::json!({"status": "updated", "workflow_id": id})),
)
} else {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Workflow not found"})),
)
}
}
/// DELETE /api/workflows/:id — Delete a workflow definition.
pub async fn delete_workflow(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> 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"})),
);
}
});
if state.kernel.workflows.remove_workflow(workflow_id).await {
(
StatusCode::OK,
Json(serde_json::json!({"status": "removed", "workflow_id": id})),
)
} else {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Workflow not found"})),
)
}
}
// ---------------------------------------------------------------------------
// Trigger routes
// ---------------------------------------------------------------------------
/// POST /api/triggers — Register a new event trigger.
pub async fn create_trigger(
State(state): State<Arc<AppState>>,
Json(req): Json<serde_json::Value>,
) -> 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<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
let agent_filter = params
.get("agent_id")
.and_then(|id| id.parse::<AgentId>().ok());
let triggers = state.kernel.list_triggers(agent_filter);
let list: Vec<serde_json::Value> = 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<Arc<AppState>>,
Path(id): Path<String>,
) -> 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<serde_json::Value> = 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<Arc<AppState>>,
Path(id): Path<String>,
Json(body): Json<SetModeRequest>,
) -> 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<Arc<AppState>>,
Path(id): Path<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 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" },
"fallback_models": entry.manifest.fallback_models,
})),
)
}
/// POST /api/agents/:id/message/stream — SSE streaming response.
pub async fn send_message_stream(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<MessageRequest>,
) -> 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<dyn KernelHandle> = state.kernel.clone() as Arc<dyn KernelHandle>;
let (rx, _handle) = match state.kernel.send_message_streaming(
agent_id,
&req.message,
Some(kernel_handle),
req.sender_id,
req.sender_name,
) {
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<Event, std::convert::Infallible> = 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)
.keep_alive(axum::response::sse::KeepAlive::default())
.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: "allowed_users", label: "Allowed User 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 (supports China & International)",
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: "region", label: "Region", field_type: FieldType::Text, env_var: None, required: false, placeholder: "cn or intl", advanced: false },
ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8453", advanced: true },
ChannelField { key: "webhook_path", label: "Webhook Path", field_type: FieldType::Text, env_var: None, required: false, placeholder: "/feishu/webhook", advanced: true },
ChannelField { key: "verification_token", label: "Verification Token", field_type: FieldType::Text, env_var: None, required: false, placeholder: "verify-token", advanced: true },
ChannelField { key: "encrypt_key_env", label: "Encrypt Key", field_type: FieldType::Secret, env_var: Some("FEISHU_ENCRYPT_KEY"), required: false, placeholder: "encrypt-key", advanced: true },
ChannelField { key: "bot_names", label: "Bot Names", field_type: FieldType::List, env_var: None, required: false, placeholder: "MyBot, Assistant", 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 (CN) or open.larksuite.com (International)", "Copy App ID and Secret", "Set region: cn (Feishu) or intl (Lark)"],
config_template: "[channels.feishu]\napp_id = \"\"\napp_secret_env = \"FEISHU_APP_SECRET\"\nregion = \"cn\"",
},
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: "dingtalk_stream", display_name: "DingTalk Stream", icon: "DS",
description: "DingTalk Stream Mode (WebSocket long-connection)",
category: "enterprise", difficulty: "Easy", setup_time: "~5 min",
quick_setup: "Create an Enterprise Internal App with Stream Mode enabled",
setup_type: "form",
fields: &[
ChannelField { key: "app_key_env", label: "App Key", field_type: FieldType::Secret, env_var: Some("DINGTALK_APP_KEY"), required: true, placeholder: "ding...", advanced: false },
ChannelField { key: "app_secret_env", label: "App Secret", field_type: FieldType::Secret, env_var: Some("DINGTALK_APP_SECRET"), required: true, placeholder: "uAn4...", advanced: false },
ChannelField { key: "robot_code_env", label: "Robot Code", field_type: FieldType::Text, env_var: Some("DINGTALK_ROBOT_CODE"), required: false, placeholder: "ding... (same as App Key)", 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 Enterprise Internal App in DingTalk Open Platform", "Enable Stream Mode in the app settings", "Add robot capability and configure permissions", "Copy App Key and App Secret below"],
config_template: "[channels.dingtalk_stream]\napp_key_env = \"DINGTALK_APP_KEY\"\napp_secret_env = \"DINGTALK_APP_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\"",
},
ChannelMeta {
name: "wecom", display_name: "WeCom", icon: "WC",
description: "WeCom (WeChat Work) adapter",
category: "messaging", difficulty: "Easy", setup_time: "~3 min",
quick_setup: "Enter your Corp ID, Agent ID, and Secret",
setup_type: "form",
fields: &[
ChannelField { key: "corp_id", label: "Corp ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "wwxxxxx", advanced: false },
ChannelField { key: "agent_id", label: "Agent ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "wwxxxxx", advanced: false },
ChannelField { key: "secret_env", label: "Secret", field_type: FieldType::Secret, env_var: Some("WECOM_SECRET"), required: true, placeholder: "secret", advanced: false },
ChannelField { key: "token", label: "Callback Token", field_type: FieldType::Text, env_var: None, required: false, placeholder: "callback_token", advanced: true },
ChannelField { key: "encoding_aes_key", label: "Encoding AES Key", field_type: FieldType::Text, env_var: None, required: false, placeholder: "encoding_aes_key", advanced: true },
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: &["Create a WeCom application at work.weixin.qq.com", "Get Corp ID, Agent ID, and Secret", "Configure callback URL to your webhook endpoint"],
config_template: "[channels.wecom]\ncorp_id = \"\"\nagent_id = \"\"\nsecret_env = \"WECOM_SECRET\"",
},
];
/// 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(),
"dingtalk_stream" => config.dingtalk_stream.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(),
"wecom" => config.wecom.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::<Vec<_>>()
.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<serde_json::Value> {
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()),
"dingtalk_stream" => config
.dingtalk_stream
.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()),
"wecom" => config
.wecom
.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<Arc<AppState>>) -> 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<serde_json::Value> = 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<Arc<AppState>>,
Path(name): Path<String>,
Json(body): Json<serde_json::Value>,
) -> 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<String, (String, FieldType)> = 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<Arc<AppState>>,
Path(name): Path<String>,
) -> 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 — Connectivity check + optional live test message.
///
/// Accepts an optional JSON body with `channel_id` (for Discord/Slack) or `chat_id`
/// (for Telegram). When provided, sends a real test message to verify the bot can
/// post to that channel.
pub async fn test_channel(
Path(name): Path<String>,
raw_body: axum::body::Bytes,
) -> 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(", "))
})),
);
}
// If a target channel/chat ID is provided, send a real test message
let body: serde_json::Value = if raw_body.is_empty() {
serde_json::Value::Null
} else {
serde_json::from_slice(&raw_body).unwrap_or(serde_json::Value::Null)
};
let target = body
.get("channel_id")
.or_else(|| body.get("chat_id"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(target_id) = target {
match send_channel_test_message(&name, &target_id).await {
Ok(()) => {
return (
StatusCode::OK,
Json(serde_json::json!({
"status": "ok",
"message": format!("Test message sent to {} channel {}.", meta.display_name, target_id)
})),
);
}
Err(e) => {
return (
StatusCode::OK,
Json(serde_json::json!({
"status": "error",
"message": format!("Credentials valid but failed to send test message: {e}")
})),
);
}
}
}
(
StatusCode::OK,
Json(serde_json::json!({
"status": "ok",
"message": format!("All required credentials for {} are set. Provide channel_id or chat_id to send a test message.", meta.display_name)
})),
)
}
/// Send a real test message to a specific channel/chat on the given platform.
async fn send_channel_test_message(channel_name: &str, target_id: &str) -> Result<(), String> {
let client = reqwest::Client::new();
let test_msg = "OpenFang test message — your channel is connected!";
match channel_name {
"discord" => {
let token = std::env::var("DISCORD_BOT_TOKEN")
.map_err(|_| "DISCORD_BOT_TOKEN not set".to_string())?;
let url = format!("https://discord.com/api/v10/channels/{target_id}/messages");
let resp = client
.post(&url)
.header("Authorization", format!("Bot {token}"))
.json(&serde_json::json!({ "content": test_msg }))
.send()
.await
.map_err(|e| format!("HTTP request failed: {e}"))?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("Discord API error: {body}"));
}
}
"telegram" => {
let token = std::env::var("TELEGRAM_BOT_TOKEN")
.map_err(|_| "TELEGRAM_BOT_TOKEN not set".to_string())?;
let url = format!("https://api.telegram.org/bot{token}/sendMessage");
let resp = client
.post(&url)
.json(&serde_json::json!({ "chat_id": target_id, "text": test_msg }))
.send()
.await
.map_err(|e| format!("HTTP request failed: {e}"))?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("Telegram API error: {body}"));
}
}
"slack" => {
let token = std::env::var("SLACK_BOT_TOKEN")
.map_err(|_| "SLACK_BOT_TOKEN not set".to_string())?;
let url = "https://slack.com/api/chat.postMessage";
let resp = client
.post(url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "channel": target_id, "text": test_msg }))
.send()
.await
.map_err(|e| format!("HTTP request failed: {e}"))?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("Slack API error: {body}"));
}
}
_ => {
return Err(format!(
"Live test messaging not supported for {channel_name}. Credentials are valid."
));
}
}
Ok(())
}
/// POST /api/channels/reload — Manually trigger a channel hot-reload from disk config.
pub async fn reload_channels(State(state): State<Arc<AppState>>) -> 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": "The WhatsApp Web gateway auto-starts with the daemon when configured. Ensure Node.js >= 18 is installed and WhatsApp is configured in config.toml. Set WHATSAPP_WEB_GATEWAY_URL to use an external gateway."
}));
}
// 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<std::collections::HashMap<String, String>>,
) -> 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<serde_json::Value, String> {
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<serde_json::Value, String> {
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::<AgentManifest>(&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<String>) -> 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::<AgentManifest>(&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<Arc<AppState>>,
Path(_id): Path<String>,
) -> 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<serde_json::Value> = 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<Arc<AppState>>,
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<Arc<AppState>>,
Path((_id, key)): Path<(String, String)>,
Json(body): Json<serde_json::Value>,
) -> 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<Arc<AppState>>,
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<Arc<AppState>>) -> impl IntoResponse {
// Run the database check on a blocking thread so we never hold the
// std::sync::Mutex<Connection> on a tokio worker thread. This prevents
// the health probe from starving the async runtime when the agent loop
// is holding the database lock for session saves.
let memory = state.kernel.memory.clone();
let db_ok = tokio::task::spawn_blocking(move || {
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,
]));
memory.structured_get(shared_id, "__health_check__").is_ok()
})
.await
.unwrap_or(false);
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<Arc<AppState>>) -> impl IntoResponse {
let health = state.kernel.supervisor.health();
let memory = state.kernel.memory.clone();
let db_ok = tokio::task::spawn_blocking(move || {
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,
]));
memory.structured_get(shared_id, "__health_check__").is_ok()
})
.await
.unwrap_or(false);
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<Arc<AppState>>) -> 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<Arc<AppState>>) -> 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<serde_json::Value> = 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<Arc<AppState>>,
Json(req): Json<SkillInstallRequest>,
) -> 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<Arc<AppState>>,
Json(req): Json<SkillUninstallRequest>,
) -> 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<HashMap<String, String>>,
) -> 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<serde_json::Value> = 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<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> 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);
let skills_dir = state.kernel.config.home_dir.join("skills");
match client.search(&query, limit).await {
Ok(results) => {
let items: Vec<serde_json::Value> = results
.results
.iter()
.map(|e| {
let installed = skills_dir.join(&e.slug).exists();
serde_json::json!({
"slug": e.slug,
"name": e.display_name,
"description": e.summary,
"version": e.version,
"score": e.score,
"updated_at": e.updated_at,
"installed": installed,
})
})
.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}");
let status = if is_clawhub_rate_limit(&e) {
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<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> 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);
let skills_dir = state.kernel.config.home_dir.join("skills");
match client.browse(sort, limit, cursor).await {
Ok(results) => {
let items: Vec<serde_json::Value> = results
.items
.iter()
.map(|entry| {
let mut json = clawhub_browse_entry_to_json(entry);
let installed = skills_dir.join(&entry.slug).exists();
json["installed"] = serde_json::json!(installed);
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 is_clawhub_rate_limit(&e) {
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<Arc<AppState>>,
Path(slug): Path<String>,
) -> 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) => {
let status = if is_clawhub_rate_limit(&e) {
StatusCode::TOO_MANY_REQUESTS
} else {
StatusCode::NOT_FOUND
};
(status, 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<Arc<AppState>>,
Path(slug): Path<String>,
) -> 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<Arc<AppState>>,
Json(req): Json<crate::types::ClawHubInstallRequest>,
) -> 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<serde_json::Value> = result
.warnings
.iter()
.map(|w| {
serde_json::json!({
"severity": format!("{:?}", w.severity),
"message": w.message,
})
})
.collect();
let translations: Vec<serde_json::Value> = 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 matches!(e, openfang_skills::SkillError::SecurityBlocked(_)) {
StatusCode::FORBIDDEN
} else if is_clawhub_rate_limit(&e) {
StatusCode::TOO_MANY_REQUESTS
} else if matches!(e, openfang_skills::SkillError::Network(_)) {
StatusCode::BAD_GATEWAY
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
tracing::warn!("ClawHub install failed: {msg}");
(status, Json(serde_json::json!({"error": msg})))
}
}
}
/// Check whether a SkillError represents a ClawHub rate-limit (429).
fn is_clawhub_rate_limit(err: &openfang_skills::SkillError) -> bool {
matches!(err, openfang_skills::SkillError::RateLimited(_))
}
/// 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<Arc<AppState>>) -> impl IntoResponse {
let defs = state.kernel.hand_registry.list_definitions();
let hands: Vec<serde_json::Value> = defs
.iter()
.map(|d| {
let reqs = state
.kernel
.hand_registry
.check_requirements(&d.id)
.unwrap_or_default();
let readiness = state.kernel.hand_registry.readiness(&d.id);
let requirements_met = readiness
.as_ref()
.map(|r| r.requirements_met)
.unwrap_or(false);
let active = readiness.as_ref().map(|r| r.active).unwrap_or(false);
let degraded = readiness.as_ref().map(|r| r.degraded).unwrap_or(false);
serde_json::json!({
"id": d.id,
"name": d.name,
"description": d.description,
"category": d.category,
"icon": d.icon,
"tools": d.tools,
"requirements_met": requirements_met,
"active": active,
"degraded": degraded,
"requirements": reqs.iter().map(|(r, ok)| serde_json::json!({
"key": r.key,
"label": r.label,
"satisfied": ok,
"optional": r.optional,
})).collect::<Vec<_>>(),
"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<Arc<AppState>>) -> impl IntoResponse {
let instances = state.kernel.hand_registry.list_instances();
let items: Vec<serde_json::Value> = 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<Arc<AppState>>,
Path(hand_id): Path<String>,
) -> 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 readiness = state.kernel.hand_registry.readiness(&hand_id);
let requirements_met = readiness
.as_ref()
.map(|r| r.requirements_met)
.unwrap_or(false);
let active = readiness.as_ref().map(|r| r.active).unwrap_or(false);
let degraded = readiness.as_ref().map(|r| r.degraded).unwrap_or(false);
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": requirements_met,
"active": active,
"degraded": degraded,
"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,
"optional": r.optional,
});
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::<Vec<_>>(),
"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::<Vec<_>>(),
"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<Arc<AppState>>,
Path(hand_id): Path<String>,
) -> 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 readiness = state.kernel.hand_registry.readiness(&hand_id);
let requirements_met = readiness
.as_ref()
.map(|r| r.requirements_met)
.unwrap_or(false);
let active = readiness.as_ref().map(|r| r.active).unwrap_or(false);
let degraded = readiness.as_ref().map(|r| r.degraded).unwrap_or(false);
(
StatusCode::OK,
Json(serde_json::json!({
"hand_id": def.id,
"requirements_met": requirements_met,
"active": active,
"degraded": degraded,
"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,
"optional": r.optional,
});
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::<Vec<_>>(),
})),
)
}
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<Arc<AppState>>,
Path(hand_id): Path<String>,
) -> 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")
|| combined.contains("already an App at")
|| combined.contains("is already installed");
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::<String>();
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::<Vec<_>>(),
})),
)
}
/// POST /api/hands/install — Install a hand from TOML content.
pub async fn install_hand(
State(state): State<Arc<AppState>>,
Json(body): Json<serde_json::Value>,
) -> 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/upsert — Install or update a hand definition.
///
/// Like `install_hand` but overwrites an existing definition with the same ID.
/// Active instances are NOT automatically restarted — deactivate + reactivate
/// to pick up the new definition.
pub async fn upsert_hand(
State(state): State<Arc<AppState>>,
Json(body): Json<serde_json::Value>,
) -> 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
.upsert_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<Arc<AppState>>,
Path(hand_id): Path<String>,
body: Option<Json<openfang_hands::ActivateHandRequest>>,
) -> 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<Arc<AppState>>,
Path(id): Path<uuid::Uuid>,
) -> 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<Arc<AppState>>,
Path(id): Path<uuid::Uuid>,
) -> 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<Arc<AppState>>,
Path(id): Path<uuid::Uuid>,
) -> 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<Arc<AppState>>,
Path(hand_id): Path<String>,
) -> 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<String, serde_json::Value> = 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<Arc<AppState>>,
Path(hand_id): Path<String>,
Json(config): Json<std::collections::HashMap<String, serde_json::Value>>,
) -> 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<Arc<AppState>>,
Path(id): Path<uuid::Uuid>,
) -> 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 shared structured memory (memory_store uses shared namespace)
let shared_id = openfang_kernel::kernel::shared_memory_agent_id();
let mut metrics = serde_json::Map::new();
for metric in &def.dashboard.metrics {
// Try shared memory first (where memory_store tool writes), fall back to agent-specific
let value = state
.kernel
.memory
.structured_get(shared_id, &metric.memory_key)
.ok()
.flatten()
.or_else(|| {
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<Arc<AppState>>,
Path(id): Path<uuid::Uuid>,
) -> 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<Arc<AppState>>) -> impl IntoResponse {
// Get configured servers from config
let config_servers: Vec<serde_json::Value> = 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<serde_json::Value> = connections
.iter()
.map(|conn| {
let tools: Vec<serde_json::Value> = 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<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> 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<serde_json::Value> = 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<Arc<AppState>>) -> 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<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> 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<axum::response::sse::Event, std::convert::Infallible>,
>(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<Arc<AppState>>) -> 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<serde_json::Value> = 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::<Vec<_>>(),
"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<Arc<AppState>>) -> 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(peer_node) = state.kernel.peer_node.get() {
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<Arc<AppState>>) -> impl IntoResponse {
let mut tools: Vec<serde_json::Value> = 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<Arc<AppState>>) -> 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<Arc<AppState>>) -> impl IntoResponse {
let agents: Vec<serde_json::Value> = 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<Arc<AppState>>) -> 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<Arc<AppState>>) -> impl IntoResponse {
match state.kernel.memory.usage().query_by_model() {
Ok(models) => {
let list: Vec<serde_json::Value> = 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<Arc<AppState>>) -> 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::<Vec<_>>(),
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<Arc<AppState>>) -> 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<Arc<AppState>>,
Json(body): Json<serde_json::Value>,
) -> 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);
}
if let Some(v) = body["default_max_llm_tokens_per_hour"].as_u64() {
(*config_ptr).budget.default_max_llm_tokens_per_hour = v;
}
}
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<Arc<AppState>>,
Path(id): Path<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 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);
// Token usage from scheduler
let token_usage = state.kernel.scheduler.get_usage(agent_id);
let tokens_used = token_usage.map(|(t, _)| t).unwrap_or(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 },
},
"tokens": {
"used": tokens_used,
"limit": quota.max_llm_tokens_per_hour,
"pct": if quota.max_llm_tokens_per_hour > 0 { tokens_used as f64 / quota.max_llm_tokens_per_hour as f64 } else { 0.0 },
},
})),
)
}
/// GET /api/budget/agents — Per-agent cost ranking (top spenders).
pub async fn agent_budget_ranking(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let usage_store = openfang_memory::usage::UsageStore::new(state.kernel.memory.usage_conn());
let agents: Vec<serde_json::Value> = 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,
"max_llm_tokens_per_hour": entry.manifest.resources.max_llm_tokens_per_hour,
}))
} 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<Arc<AppState>>,
Path(id): Path<String>,
Json(body): Json<serde_json::Value>,
) -> 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();
let tokens = body["max_llm_tokens_per_hour"].as_u64();
if hourly.is_none() && daily.is_none() && monthly.is_none() && tokens.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, max_llm_tokens_per_hour"}),
),
);
}
match state
.kernel
.registry
.update_resources(agent_id, hourly, daily, monthly, tokens)
{
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<Arc<AppState>>) -> 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<Arc<AppState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
let session_id = match id.parse::<uuid::Uuid>() {
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<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<serde_json::Value>,
) -> impl IntoResponse {
let session_id = match id.parse::<uuid::Uuid>() {
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<Arc<AppState>>,
Path((agent_id_str, label)): Path<(String, String)>,
) -> impl IntoResponse {
let agent_id = match agent_id_str.parse::<uuid::Uuid>() {
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<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<serde_json::Value>,
) -> 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<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<AgentUpdateRequest>,
) -> 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.",
})),
)
}
/// PATCH /api/agents/{id} — Partial update of agent fields (name, description, model, system_prompt).
pub async fn patch_agent(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(body): Json<serde_json::Value>,
) -> 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"})),
);
}
// Apply partial updates using dedicated registry methods
if let Some(name) = body.get("name").and_then(|v| v.as_str()) {
if let Err(e) = state
.kernel
.registry
.update_name(agent_id, name.to_string())
{
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": format!("{e}")})),
);
}
}
if let Some(desc) = body.get("description").and_then(|v| v.as_str()) {
if let Err(e) = state
.kernel
.registry
.update_description(agent_id, desc.to_string())
{
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": format!("{e}")})),
);
}
}
if let Some(model) = body.get("model").and_then(|v| v.as_str()) {
let explicit_provider = body.get("provider").and_then(|v| v.as_str());
if let Err(e) = state
.kernel
.set_agent_model(agent_id, model, explicit_provider)
{
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": format!("{e}")})),
);
}
}
if let Some(system_prompt) = body.get("system_prompt").and_then(|v| v.as_str()) {
if let Err(e) = state
.kernel
.registry
.update_system_prompt(agent_id, system_prompt.to_string())
{
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": format!("{e}")})),
);
}
}
// Persist updated entry to SQLite
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", "agent_id": entry.id.to_string(), "name": entry.name}),
),
)
} else {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Agent vanished during update"})),
)
}
}
// ---------------------------------------------------------------------------
// Migration endpoint
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Security dashboard endpoint
// ---------------------------------------------------------------------------
/// GET /api/security — Security feature status for the dashboard.
pub async fn security_status(State(state): State<Arc<AppState>>) -> 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<MigrateScanRequest>) -> 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<MigrateRequest>) -> 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<serde_json::Value> = report
.imported
.iter()
.map(|i| {
serde_json::json!({
"kind": format!("{}", i.kind),
"name": i.name,
"destination": i.destination,
})
})
.collect();
let skipped: Vec<serde_json::Value> = 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<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> 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<serde_json::Value> = 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| {
// Custom models from unknown providers are assumed available
let available = catalog
.get_provider(&m.provider)
.map(|p| p.auth_status != openfang_types::model_catalog::AuthStatus::Missing)
.unwrap_or(m.tier == openfang_types::model_catalog::ModelTier::Custom);
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<Arc<AppState>>) -> impl IntoResponse {
let aliases = state
.kernel
.model_catalog
.read()
.unwrap_or_else(|e| e.into_inner())
.list_aliases()
.clone();
let entries: Vec<serde_json::Value> = 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<Arc<AppState>>,
Path(id): Path<String>,
) -> 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(m.tier == openfang_types::model_catalog::ModelTier::Custom);
(
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.
///
/// Probes run **concurrently** and results are **cached for 60 seconds** so the
/// endpoint responds instantly on repeated dashboard loads even when local
/// providers are unreachable (fixes #474).
pub async fn list_providers(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let provider_list: Vec<openfang_types::model_catalog::ProviderInfo> = {
let catalog = state
.kernel
.model_catalog
.read()
.unwrap_or_else(|e| e.into_inner());
catalog.list_providers().to_vec()
};
// Collect local providers that need probing
let local_providers: Vec<(usize, String, String)> = provider_list
.iter()
.enumerate()
.filter(|(_, p)| !p.key_required && !p.base_url.is_empty())
.map(|(i, p)| (i, p.id.clone(), p.base_url.clone()))
.collect();
// Fire all probes concurrently (cached results return instantly)
let cache = &state.provider_probe_cache;
let probe_futures: Vec<_> = local_providers
.iter()
.map(|(_, id, url)| {
openfang_runtime::provider_health::probe_provider_cached(id, url, cache)
})
.collect();
let probe_results = futures::future::join_all(probe_futures).await;
// Index probe results by provider list position for O(1) lookup
let mut probe_map: HashMap<usize, openfang_runtime::provider_health::ProbeResult> =
HashMap::with_capacity(local_providers.len());
for ((idx, _, _), result) in local_providers.iter().zip(probe_results.into_iter()) {
probe_map.insert(*idx, result);
}
let mut providers: Vec<serde_json::Value> = Vec::with_capacity(provider_list.len());
for (i, p) in provider_list.iter().enumerate() {
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, attach the probe result
if let Some(probe) = probe_map.remove(&i) {
entry["is_local"] = serde_json::json!(true);
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);
}
} else if !p.key_required {
// Local provider with empty base_url (e.g. claude-code) — skip probing
entry["is_local"] = serde_json::json!(true);
}
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<Arc<AppState>>,
Json(body): Json<serde_json::Value>,
) -> 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 for provider '{}'", id, provider)}),
),
);
}
// 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<Arc<AppState>>,
axum::extract::Path(model_id): axum::extract::Path<String>,
) -> 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<Arc<AppState>>) -> 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<Arc<AppState>>) -> impl IntoResponse {
let agents = state.kernel.registry.list();
let base_url = format!("http://{}", state.kernel.config.api_listen);
let cards: Vec<serde_json::Value> = 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<Arc<AppState>>,
Json(request): Json<serde_json::Value>,
) -> 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.into(),
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<Arc<AppState>>,
Path(task_id): Path<String>,
) -> 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<Arc<AppState>>,
Path(task_id): Path<String>,
) -> 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<Arc<AppState>>) -> impl IntoResponse {
let agents = state
.kernel
.a2a_external_agents
.lock()
.unwrap_or_else(|e| e.into_inner());
let items: Vec<serde_json::Value> = agents
.iter()
.map(|(_, card)| {
serde_json::json!({
"name": card.name,
"url": card.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<Arc<AppState>>,
Json(body): Json<serde_json::Value>,
) -> 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<Arc<AppState>>,
Json(body): Json<serde_json::Value>,
) -> 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<Arc<AppState>>,
Path(task_id): Path<String>,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> 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<Arc<AppState>>,
Json(request): Json<serde_json::Value>,
) -> 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<dyn openfang_runtime::kernel_handle::KernelHandle> =
state.kernel.clone() as Arc<dyn openfang_runtime::kernel_handle::KernelHandle>;
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<Arc<AppState>>,
Path(id): Path<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"})),
)
}
};
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<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<serde_json::Value>,
) -> 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<Arc<AppState>>,
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::<uuid::Uuid>() {
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<Arc<AppState>>,
Path(id): Path<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"})),
)
}
};
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<Arc<AppState>>,
Path(id): Path<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"})),
)
}
};
if state.kernel.registry.get(agent_id).is_none() {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Agent not found"})),
);
}
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<Arc<AppState>>,
Path(id): Path<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"})),
)
}
};
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<Arc<AppState>>,
Path(id): Path<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"})),
)
}
};
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<Arc<AppState>>,
Path(id): Path<String>,
Json(body): Json<serde_json::Value>,
) -> 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"})),
)
}
};
let explicit_provider = body["provider"].as_str();
match state
.kernel
.set_agent_model(agent_id, model, explicit_provider)
{
Ok(()) => {
// Return the resolved model+provider so frontend stays in sync.
// The model name may have been normalized (provider prefix stripped),
// so we read it back from the registry instead of echoing the raw input.
let (resolved_model, resolved_provider) = state
.kernel
.registry
.get(agent_id)
.map(|e| {
(
e.manifest.model.model.clone(),
e.manifest.model.provider.clone(),
)
})
.unwrap_or_else(|| (model.to_string(), String::new()));
(
StatusCode::OK,
Json(
serde_json::json!({"status": "ok", "model": resolved_model, "provider": resolved_provider}),
),
)
}
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<Arc<AppState>>,
Path(id): Path<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 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<Arc<AppState>>,
Path(id): Path<String>,
Json(body): Json<serde_json::Value>,
) -> 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::<Vec<_>>()
});
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::<Vec<_>>()
});
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<Arc<AppState>>,
Path(id): Path<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 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<Arc<AppState>>,
Path(id): Path<String>,
Json(body): Json<serde_json::Value>,
) -> 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<String> = 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<Arc<AppState>>,
Path(id): Path<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 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<String> = 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<Arc<AppState>>,
Path(id): Path<String>,
Json(body): Json<serde_json::Value>,
) -> 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<String> = 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<Arc<AppState>>,
Path(name): Path<String>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
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"})),
);
}
};
// Look up env var from catalog; for unknown/custom providers derive one.
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_else(|| {
// Custom provider — derive env var: MY_PROVIDER → MY_PROVIDER_API_KEY
format!("{}_API_KEY", name.to_uppercase().replace('-', "_"))
})
};
// Store in vault (best-effort — no-op if vault not initialized)
state.kernel.store_credential(&env_var, &key);
// Write to secrets.env file (dual-write for backward compat / vault corruption recovery)
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();
// Auto-switch default provider if current default has no working key.
// This fixes the common case where a user adds e.g. a Gemini key via dashboard
// but their agent still tries to use the previous provider (which has no key).
//
// Read the effective default from the hot-reload override (if set) rather than
// the stale boot-time config — a previous set_provider_key call may have already
// switched the default.
let (current_provider, current_key_env) = {
let guard = state
.kernel
.default_model_override
.read()
.unwrap_or_else(|e| e.into_inner());
match guard.as_ref() {
Some(dm) => (dm.provider.clone(), dm.api_key_env.clone()),
None => (
state.kernel.config.default_model.provider.clone(),
state.kernel.config.default_model.api_key_env.clone(),
),
}
};
let current_has_key = if current_key_env.is_empty() {
false
} else {
std::env::var(&current_key_env)
.ok()
.filter(|v| !v.is_empty())
.is_some()
};
let switched = if !current_has_key && current_provider != name {
// Find a default model for the newly-keyed provider
let default_model = {
let catalog = state
.kernel
.model_catalog
.read()
.unwrap_or_else(|e| e.into_inner());
catalog.default_model_for_provider(&name)
};
if let Some(model_id) = default_model {
// Update config.toml to persist the switch
let config_path = state.kernel.config.home_dir.join("config.toml");
let update_toml = format!(
"\n[default_model]\nprovider = \"{}\"\nmodel = \"{}\"\napi_key_env = \"{}\"\n",
name, model_id, env_var
);
backup_config(&config_path);
if let Ok(existing) = std::fs::read_to_string(&config_path) {
let cleaned = remove_toml_section(&existing, "default_model");
let _ =
std::fs::write(&config_path, format!("{}\n{}", cleaned.trim(), update_toml));
} else {
let _ = std::fs::write(&config_path, update_toml);
}
// Hot-update the in-memory default model override so resolve_driver()
// immediately creates drivers for the new provider — no restart needed.
{
let new_dm = openfang_types::config::DefaultModelConfig {
provider: name.clone(),
model: model_id,
api_key_env: env_var.clone(),
base_url: None,
};
let mut guard = state
.kernel
.default_model_override
.write()
.unwrap_or_else(|e| e.into_inner());
*guard = Some(new_dm);
}
true
} else {
false
}
} else if current_provider == name {
// User is saving a key for the CURRENT default provider. The env var is
// already set (set_var above), but we must ensure default_model_override
// has the correct api_key_env so resolve_driver reads the right variable.
let needs_update = {
let guard = state
.kernel
.default_model_override
.read()
.unwrap_or_else(|e| e.into_inner());
match guard.as_ref() {
Some(dm) => dm.api_key_env != env_var,
None => state.kernel.config.default_model.api_key_env != env_var,
}
};
if needs_update {
let mut guard = state
.kernel
.default_model_override
.write()
.unwrap_or_else(|e| e.into_inner());
let base = guard
.clone()
.unwrap_or_else(|| state.kernel.config.default_model.clone());
*guard = Some(openfang_types::config::DefaultModelConfig {
api_key_env: env_var.clone(),
..base
});
}
false
} else {
false
};
let mut resp = serde_json::json!({"status": "saved", "provider": name});
if switched {
resp["switched_default"] = serde_json::json!(true);
resp["message"] = serde_json::json!(format!(
"API key saved and default provider switched to '{}'.",
name
));
}
(StatusCode::OK, Json(resp))
}
/// DELETE /api/providers/{name}/key — Remove an API key for a provider.
pub async fn delete_provider_key(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
) -> impl IntoResponse {
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_else(|| {
// Custom/unknown provider — derive env var from convention
format!("{}_API_KEY", name.to_uppercase().replace('-', "_"))
})
};
if env_var.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Provider does not require an API key"})),
);
}
// Remove from vault (best-effort)
state.kernel.remove_credential(&env_var);
// 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<Arc<AppState>>,
Path(name): Path<String>,
) -> 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)
},
skip_permissions: true,
};
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<Arc<AppState>>,
Path(name): Path<String>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
// Accept any provider name — custom providers are supported via OpenAI-compatible format.
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<dyn std::error::Error>> {
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<Arc<AppState>>,
Json(body): Json<serde_json::Value>,
) -> 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<String> = 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<String> = 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.<name>]` section in config.toml with the given non-secret fields.
fn upsert_channel_config(
config_path: &std::path::Path,
channel_name: &str,
fields: &HashMap<String, (String, FieldType)>,
) -> Result<(), Box<dyn std::error::Error>> {
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::<i64>() {
toml::Value::Integer(n)
} else {
toml::Value::String(v.clone())
}
}
FieldType::List => {
// Always store list items as strings so that numeric IDs
// (e.g. Discord guild snowflakes, Telegram user IDs) are
// deserialized correctly into Vec<String> config fields.
let items: Vec<toml::Value> = v
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| toml::Value::String(s.to_string()))
.collect();
toml::Value::Array(items)
}
_ => 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.<name>]` section from config.toml.
fn remove_channel_config(
config_path: &std::path::Path,
channel_name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
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<Arc<AppState>>) -> 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<Arc<AppState>>) -> impl IntoResponse {
let registry = state
.kernel
.extension_registry
.read()
.unwrap_or_else(|e| e.into_inner());
let templates: Vec<serde_json::Value> = 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::<Vec<_>>(),
"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<Arc<AppState>>,
Json(req): Json<serde_json::Value>,
) -> 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<Arc<AppState>>,
Path(id): Path<String>,
) -> 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<Arc<AppState>>,
Path(id): Path<String>,
) -> 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<Arc<AppState>>) -> impl IntoResponse {
let health_entries = state.kernel.extension_health.all_health();
let entries: Vec<serde_json::Value> = 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<Arc<AppState>>) -> 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<Arc<AppState>>) -> 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<Arc<AppState>>,
Json(req): Json<serde_json::Value>,
) -> 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::<AgentId>() {
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<serde_json::Value> =
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<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<serde_json::Value>,
) -> impl IntoResponse {
let shared_id = schedule_shared_agent_id();
let mut schedules: Vec<serde_json::Value> =
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<Arc<AppState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
let shared_id = schedule_shared_agent_id();
let mut schedules: Vec<serde_json::Value> =
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<Arc<AppState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
let shared_id = schedule_shared_agent_id();
let schedules: Vec<serde_json::Value> =
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::<AgentId>() {
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<serde_json::Value> =
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<dyn KernelHandle> = state.kernel.clone() as Arc<dyn KernelHandle>;
match state
.kernel
.send_message_with_handle(target_agent, &run_message, Some(kernel_handle), None, None)
.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<String>,
pub avatar_url: Option<String>,
pub color: Option<String>,
#[serde(default)]
pub archetype: Option<String>,
#[serde(default)]
pub vibe: Option<String>,
#[serde(default)]
pub greeting_style: Option<String>,
}
/// PATCH /api/agents/{id}/identity — Update an agent's visual identity.
pub async fn update_agent_identity(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<UpdateIdentityRequest>,
) -> 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(()) => {
// Persist identity to SQLite
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", "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<String>,
pub description: Option<String>,
pub system_prompt: Option<String>,
pub emoji: Option<String>,
pub avatar_url: Option<String>,
pub color: Option<String>,
pub archetype: Option<String>,
pub vibe: Option<String>,
pub greeting_style: Option<String>,
pub model: Option<String>,
pub provider: Option<String>,
pub api_key_env: Option<String>,
pub base_url: Option<String>,
pub fallback_models: Option<Vec<openfang_types::agent::FallbackModel>>,
}
/// PATCH /api/agents/{id}/config — Hot-update agent name, description, system prompt, and identity.
pub async fn patch_agent_config(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<PatchAgentConfigRequest>,
) -> 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 — use set_agent_model for catalog-based provider
// resolution when provider is not explicitly provided (fixes #387/#466:
// changing model from another provider without specifying provider now
// auto-resolves the correct provider from the model catalog).
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() {
// Explicit provider given — use it directly
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 {
// Provider is empty string — resolve from catalog
if let Err(e) = state.kernel.set_agent_model(agent_id, new_model, None) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("{e}")})),
);
}
}
} else {
// No provider field at all — resolve from catalog
if let Err(e) = state.kernel.set_agent_model(agent_id, new_model, None) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("{e}")})),
);
}
}
}
}
// Update fallback model chain
if let Some(fallbacks) = req.fallback_models {
if state
.kernel
.registry
.update_fallback_models(agent_id, fallbacks)
.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<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<CloneAgentRequest>,
) -> 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());
// Register in channel router so binding resolution finds the cloned agent
if let Some(ref mgr) = *state.bridge_manager.lock().await {
mgr.router().register_agent(req.new_name.clone(), new_id);
}
(
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<Arc<AppState>>,
Path(id): Path<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 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<Arc<AppState>>,
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<Arc<AppState>>,
Path((id, filename)): Path<(String, String)>,
Json(req): Json<SetAgentFileRequest>,
) -> 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<String>,
}
/// Metadata stored alongside uploaded files.
struct UploadMeta {
#[allow(dead_code)]
filename: String,
content_type: String,
}
/// In-memory upload metadata registry.
static UPLOAD_REGISTRY: LazyLock<DashMap<String, UploadMeta>> = 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<Arc<AppState>>,
Path(id): Path<String>,
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<String>) -> 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<Arc<AppState>>) -> 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<serde_json::Value> = 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<Arc<AppState>>,
Json(req): Json<CreateApprovalRequest>,
) -> 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<Arc<AppState>>,
Path(id): Path<String>,
) -> 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<Arc<AppState>>,
Path(id): Path<String>,
) -> 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<Arc<AppState>>) -> 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::<Vec<_>>(),
"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<Arc<AppState>>) -> 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<String> = catalog
.list_providers()
.iter()
.map(|p| p.id.clone())
.collect();
let model_options: Vec<serde_json::Value> = 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": {
"general": {
"root_level": true,
"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<Arc<AppState>>,
Json(body): Json<serde_json::Value>,
) -> 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<Arc<AppState>>,
Path(id): Path<String>,
Query(params): Query<HashMap<String, String>>,
) -> 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::<usize>().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<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> 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<serde_json::Value> = 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<Arc<AppState>>,
Json(body): Json<serde_json::Value>,
) -> 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<Arc<AppState>>,
Path(id): Path<String>,
) -> 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<Arc<AppState>>,
Path(id): Path<String>,
Json(body): Json<serde_json::Value>,
) -> 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<Arc<AppState>>,
Path(id): Path<String>,
) -> 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<Arc<AppState>>,
headers: axum::http::HeaderMap,
Json(body): Json<openfang_types::webhook::WakePayload>,
) -> 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<Arc<AppState>>,
headers: axum::http::HeaderMap,
Json(body): Json<openfang_types::webhook::AgentHookPayload>,
) -> 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<Arc<AppState>>) -> 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<Arc<AppState>>,
Json(binding): Json<openfang_types::config::AgentBinding>,
) -> 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::<uuid::Uuid>().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<Arc<AppState>>,
Path(index): Path<usize>,
) -> 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<Arc<AppState>>) -> 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<Arc<AppState>>,
Json(body): Json<serde_json::Value>,
) -> 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<Arc<AppState>>) -> 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<Arc<AppState>>,
Path(device_id): Path<String>,
) -> 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<Arc<AppState>>,
Json(body): Json<serde_json::Value>,
) -> 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<Arc<AppState>>) -> 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<DashMap<String, CopilotFlowState>> = 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<Arc<AppState>>,
Path(poll_id): Path<String>,
) -> 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 } => {
// Store in vault (best-effort)
state.kernel.store_credential("GITHUB_TOKEN", &access_token);
// Save to secrets.env (dual-write)
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<Arc<AppState>>) -> impl IntoResponse {
use openfang_types::comms::{EdgeKind, TopoEdge, TopoNode, Topology};
let agents = state.kernel.registry.list();
let nodes: Vec<TopoNode> = 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<TopoEdge> = 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<openfang_types::comms::CommsEvent> {
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<openfang_types::comms::CommsEvent> {
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<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
let limit = params
.get("limit")
.and_then(|v| v.parse::<usize>().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<openfang_types::comms::CommsEvent> = 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<String> =
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<Arc<AppState>>) -> axum::response::Response {
use axum::response::sse::{Event, KeepAlive, Sse};
let (tx, rx) = tokio::sync::mpsc::channel::<
Result<axum::response::sse::Event, std::convert::Infallible>,
>(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<Arc<AppState>>,
Json(req): Json<openfang_types::comms::CommsSendRequest>,
) -> 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<Arc<AppState>>,
Json(req): Json<openfang_types::comms::CommsTaskRequest>,
) -> 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}")})),
),
}
}
// ── Dashboard Authentication (username/password sessions) ──
/// POST /api/auth/login — Authenticate with username/password, returns session token.
pub async fn auth_login(
State(state): State<Arc<AppState>>,
Json(req): Json<serde_json::Value>,
) -> axum::response::Response {
use axum::body::Body;
use axum::response::Response;
let auth_cfg = &state.kernel.config.auth;
if !auth_cfg.enabled {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({"error": "Auth not enabled"}).to_string(),
))
.unwrap();
}
let username = req.get("username").and_then(|v| v.as_str()).unwrap_or("");
let password = req.get("password").and_then(|v| v.as_str()).unwrap_or("");
// Constant-time username comparison to prevent timing attacks
let username_ok = {
use subtle::ConstantTimeEq;
let stored = auth_cfg.username.as_bytes();
let provided = username.as_bytes();
if stored.len() != provided.len() {
false
} else {
bool::from(stored.ct_eq(provided))
}
};
if !username_ok || !crate::session_auth::verify_password(password, &auth_cfg.password_hash) {
// Audit log the failed attempt
state.kernel.audit_log.record(
"system",
openfang_runtime::audit::AuditAction::AuthAttempt,
"dashboard login failed",
format!("username: {username}"),
);
return Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({"error": "Invalid credentials"}).to_string(),
))
.unwrap();
}
// Derive the session secret the same way as server.rs
let api_key = state.kernel.config.api_key.trim().to_string();
let secret = if !api_key.is_empty() {
api_key
} else {
auth_cfg.password_hash.clone()
};
let token =
crate::session_auth::create_session_token(username, &secret, auth_cfg.session_ttl_hours);
let ttl_secs = auth_cfg.session_ttl_hours * 3600;
let cookie =
format!("openfang_session={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={ttl_secs}");
state.kernel.audit_log.record(
"system",
openfang_runtime::audit::AuditAction::AuthAttempt,
"dashboard login success",
format!("username: {username}"),
);
Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json")
.header("set-cookie", &cookie)
.body(Body::from(
serde_json::json!({
"status": "ok",
"token": token,
"username": username,
})
.to_string(),
))
.unwrap()
}
/// POST /api/auth/logout — Clear the session cookie.
pub async fn auth_logout() -> impl IntoResponse {
let cookie = "openfang_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0";
(
StatusCode::OK,
[("content-type", "application/json"), ("set-cookie", cookie)],
serde_json::json!({"status": "ok"}).to_string(),
)
}
/// GET /api/auth/check — Check current authentication state.
pub async fn auth_check(
State(state): State<Arc<AppState>>,
request: axum::http::Request<axum::body::Body>,
) -> impl IntoResponse {
let auth_cfg = &state.kernel.config.auth;
if !auth_cfg.enabled {
return Json(serde_json::json!({
"authenticated": true,
"mode": "none",
}));
}
// Derive the session secret the same way as server.rs
let api_key = state.kernel.config.api_key.trim().to_string();
let secret = if !api_key.is_empty() {
api_key
} else {
auth_cfg.password_hash.clone()
};
// Check session cookie
let session_user = request
.headers()
.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|cookies| {
cookies.split(';').find_map(|c| {
c.trim()
.strip_prefix("openfang_session=")
.map(|v| v.to_string())
})
})
.and_then(|token| crate::session_auth::verify_session_token(&token, &secret));
if let Some(username) = session_user {
Json(serde_json::json!({
"authenticated": true,
"mode": "session",
"username": username,
}))
} else {
Json(serde_json::json!({
"authenticated": false,
"mode": "session",
}))
}
}
/// Remove a `[section]` and its contents from a TOML string.
#[allow(dead_code)]
fn backup_config(config_path: &std::path::Path) {
let backup = config_path.with_extension("toml.bak");
let _ = std::fs::copy(config_path, backup);
}
fn remove_toml_section(content: &str, section: &str) -> String {
let header = format!("[{}]", section);
let mut result = String::new();
let mut skipping = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == header {
skipping = true;
continue;
}
if skipping && trimmed.starts_with('[') {
skipping = false;
}
if !skipping {
result.push_str(line);
result.push('\n');
}
}
result
}
#[cfg(test)]
mod channel_config_tests {
use super::*;
#[test]
fn test_is_channel_configured_wecom_none() {
let config = openfang_types::config::ChannelsConfig::default();
assert!(!is_channel_configured(&config, "wecom"));
}
#[test]
fn test_is_channel_configured_wecom_some() {
let mut config = openfang_types::config::ChannelsConfig::default();
config.wecom = Some(openfang_types::config::WeComConfig {
corp_id: "test_corp".to_string(),
agent_id: "test_agent".to_string(),
secret_env: "WECOM_SECRET".to_string(),
webhook_port: 8454,
token: Some("token".to_string()),
encoding_aes_key: Some("aes_key".to_string()),
default_agent: Some("assistant".to_string()),
overrides: openfang_types::config::ChannelOverrides::default(),
});
assert!(is_channel_configured(&config, "wecom"));
}
#[test]
fn test_wecom_in_channel_registry() {
let wecom_meta = CHANNEL_REGISTRY.iter().find(|c| c.name == "wecom");
assert!(wecom_meta.is_some());
let meta = wecom_meta.unwrap();
assert_eq!(meta.display_name, "WeCom");
assert_eq!(meta.category, "messaging");
assert!(
meta.fields
.iter()
.find(|f| f.key == "corp_id")
.unwrap()
.required
);
assert!(
meta.fields
.iter()
.find(|f| f.key == "secret_env")
.unwrap()
.required
);
}
}