//! REST API handlers for the web dashboard. //! //! All `/api/*` routes require bearer token authentication (PairingGuard). use super::AppState; use axum::{ extract::{Path, Query, State}, http::{header, HeaderMap, StatusCode}, response::{IntoResponse, Json}, }; use serde::Deserialize; const MASKED_SECRET: &str = "***MASKED***"; // ── Bearer token auth extractor ───────────────────────────────── /// Extract and validate bearer token from Authorization header. fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> { headers .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .and_then(|auth| auth.strip_prefix("Bearer ")) } /// Verify bearer token against PairingGuard. Returns error response if unauthorized. fn require_auth( state: &AppState, headers: &HeaderMap, ) -> Result<(), (StatusCode, Json)> { if !state.pairing.require_pairing() { return Ok(()); } let token = extract_bearer_token(headers).unwrap_or(""); if state.pairing.is_authenticated(token) { Ok(()) } else { Err(( StatusCode::UNAUTHORIZED, Json(serde_json::json!({ "error": "Unauthorized — pair first via POST /pair, then send Authorization: Bearer " })), )) } } // ── Query parameters ───────────────────────────────────────────── #[derive(Deserialize)] pub struct MemoryQuery { pub query: Option, pub category: Option, } #[derive(Deserialize)] pub struct MemoryStoreBody { pub key: String, pub content: String, pub category: Option, } #[derive(Deserialize)] pub struct CronRunsQuery { pub limit: Option, } #[derive(Deserialize)] pub struct CronAddBody { pub name: Option, pub schedule: String, pub command: Option, pub job_type: Option, pub prompt: Option, pub delivery: Option, pub session_target: Option, pub model: Option, pub allowed_tools: Option>, pub delete_after_run: Option, } #[derive(Deserialize)] pub struct CronPatchBody { pub name: Option, pub schedule: Option, pub command: Option, pub prompt: Option, } // ── Handlers ──────────────────────────────────────────────────── /// GET /api/status — system status overview pub async fn handle_api_status( State(state): State, headers: HeaderMap, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } let config = state.config.lock().clone(); let health = crate::health::snapshot(); let mut channels = serde_json::Map::new(); for (channel, present) in config.channels_config.channels() { channels.insert(channel.name().to_string(), serde_json::Value::Bool(present)); } let body = serde_json::json!({ "provider": config.default_provider, "model": state.model, "temperature": state.temperature, "uptime_seconds": health.uptime_seconds, "gateway_port": config.gateway.port, "locale": "en", "memory_backend": state.mem.name(), "paired": state.pairing.is_paired(), "channels": channels, "health": health, }); Json(body).into_response() } /// GET /api/config — current config (api_key masked) pub async fn handle_api_config_get( State(state): State, headers: HeaderMap, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } let config = state.config.lock().clone(); // Serialize to TOML after masking sensitive fields. let masked_config = mask_sensitive_fields(&config); let toml_str = match toml::to_string_pretty(&masked_config) { Ok(s) => s, Err(e) => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Failed to serialize config: {e}")})), ) .into_response(); } }; Json(serde_json::json!({ "format": "toml", "content": toml_str, })) .into_response() } /// PUT /api/config — update config from TOML body pub async fn handle_api_config_put( State(state): State, headers: HeaderMap, body: String, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } // Parse the incoming TOML let incoming: crate::config::Config = match toml::from_str(&body) { Ok(c) => c, Err(e) => { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": format!("Invalid TOML: {e}")})), ) .into_response(); } }; let current_config = state.config.lock().clone(); let new_config = hydrate_config_for_save(incoming, ¤t_config); if let Err(e) = new_config.validate() { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": format!("Invalid config: {e}")})), ) .into_response(); } // Save to disk if let Err(e) = new_config.save().await { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Failed to save config: {e}")})), ) .into_response(); } // Update in-memory config *state.config.lock() = new_config; Json(serde_json::json!({"status": "ok"})).into_response() } /// GET /api/tools — list registered tool specs pub async fn handle_api_tools( State(state): State, headers: HeaderMap, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } let tools: Vec = state .tools_registry .iter() .map(|spec| { serde_json::json!({ "name": spec.name, "description": spec.description, "parameters": spec.parameters, }) }) .collect(); Json(serde_json::json!({"tools": tools})).into_response() } /// GET /api/cron — list cron jobs pub async fn handle_api_cron_list( State(state): State, headers: HeaderMap, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } let config = state.config.lock().clone(); match crate::cron::list_jobs(&config) { Ok(jobs) => Json(serde_json::json!({"jobs": jobs})).into_response(), Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Failed to list cron jobs: {e}")})), ) .into_response(), } } /// POST /api/cron — add a new cron job pub async fn handle_api_cron_add( State(state): State, headers: HeaderMap, Json(body): Json, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } let CronAddBody { name, schedule, command, job_type, prompt, delivery, session_target, model, allowed_tools, delete_after_run, } = body; let config = state.config.lock().clone(); let schedule = crate::cron::Schedule::Cron { expr: schedule, tz: None, }; if let Err(e) = crate::cron::validate_delivery_config(delivery.as_ref()) { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": format!("Failed to add cron job: {e}")})), ) .into_response(); } // Determine job type: explicit field, or infer "agent" when prompt is provided. let is_agent = matches!(job_type.as_deref(), Some("agent")) || (job_type.is_none() && prompt.is_some()); let result = if is_agent { let prompt = match prompt.as_deref() { Some(p) if !p.trim().is_empty() => p, _ => { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Missing 'prompt' for agent job"})), ) .into_response(); } }; let session_target = session_target .as_deref() .map(crate::cron::SessionTarget::parse) .unwrap_or_default(); let default_delete = matches!(schedule, crate::cron::Schedule::At { .. }); let delete_after_run = delete_after_run.unwrap_or(default_delete); crate::cron::add_agent_job( &config, name, schedule, prompt, session_target, model, delivery, delete_after_run, allowed_tools, ) } else { let command = match command.as_deref() { Some(c) if !c.trim().is_empty() => c, _ => { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Missing 'command' for shell job"})), ) .into_response(); } }; crate::cron::add_shell_job_with_approval(&config, name, schedule, command, delivery, false) }; match result { Ok(job) => Json(serde_json::json!({"status": "ok", "job": job})).into_response(), Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Failed to add cron job: {e}")})), ) .into_response(), } } /// GET /api/cron/:id/runs — list recent runs for a cron job pub async fn handle_api_cron_runs( State(state): State, headers: HeaderMap, Path(id): Path, Query(params): Query, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } let limit = params.limit.unwrap_or(20).clamp(1, 100) as usize; let config = state.config.lock().clone(); // Verify the job exists before listing runs. if let Err(e) = crate::cron::get_job(&config, &id) { return ( StatusCode::NOT_FOUND, Json(serde_json::json!({"error": format!("Cron job not found: {e}")})), ) .into_response(); } match crate::cron::list_runs(&config, &id, limit) { Ok(runs) => { let runs_json: Vec = runs .iter() .map(|r| { serde_json::json!({ "id": r.id, "job_id": r.job_id, "started_at": r.started_at.to_rfc3339(), "finished_at": r.finished_at.to_rfc3339(), "status": r.status, "output": r.output, "duration_ms": r.duration_ms, }) }) .collect(); Json(serde_json::json!({"runs": runs_json})).into_response() } Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Failed to list cron runs: {e}")})), ) .into_response(), } } /// PATCH /api/cron/:id — update an existing cron job pub async fn handle_api_cron_patch( State(state): State, headers: HeaderMap, Path(id): Path, Json(body): Json, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } let config = state.config.lock().clone(); // Build the schedule from the provided expression string (if any). let schedule = match body.schedule { Some(expr) if !expr.trim().is_empty() => Some(crate::cron::Schedule::Cron { expr: expr.trim().to_string(), tz: None, }), _ => None, }; // Route the edited text to the correct field based on the job's stored type. // The frontend sends a single textarea value; for agent jobs it is the prompt, // for shell jobs it is the command. let existing = match crate::cron::get_job(&config, &id) { Ok(j) => j, Err(e) => { return ( StatusCode::NOT_FOUND, Json(serde_json::json!({"error": format!("Cron job not found: {e}")})), ) .into_response(); } }; let is_agent = matches!(existing.job_type, crate::cron::JobType::Agent); let (patch_command, patch_prompt) = if is_agent { (None, body.command.or(body.prompt)) } else { (body.command.or(body.prompt), None) }; let patch = crate::cron::CronJobPatch { name: body.name, schedule, command: patch_command, prompt: patch_prompt, ..crate::cron::CronJobPatch::default() }; match crate::cron::update_shell_job_with_approval(&config, &id, patch, false) { Ok(job) => Json(serde_json::json!({"status": "ok", "job": job})).into_response(), Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Failed to update cron job: {e}")})), ) .into_response(), } } /// DELETE /api/cron/:id — remove a cron job pub async fn handle_api_cron_delete( State(state): State, headers: HeaderMap, Path(id): Path, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } let config = state.config.lock().clone(); match crate::cron::remove_job(&config, &id) { Ok(()) => Json(serde_json::json!({"status": "ok"})).into_response(), Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Failed to remove cron job: {e}")})), ) .into_response(), } } /// GET /api/cron/settings — return cron subsystem settings pub async fn handle_api_cron_settings_get( State(state): State, headers: HeaderMap, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } let config = state.config.lock().clone(); Json(serde_json::json!({ "enabled": config.cron.enabled, "catch_up_on_startup": config.cron.catch_up_on_startup, "max_run_history": config.cron.max_run_history, })) .into_response() } /// PATCH /api/cron/settings — update cron subsystem settings pub async fn handle_api_cron_settings_patch( State(state): State, headers: HeaderMap, Json(body): Json, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } let mut config = state.config.lock().clone(); if let Some(v) = body.get("enabled").and_then(|v| v.as_bool()) { config.cron.enabled = v; } if let Some(v) = body.get("catch_up_on_startup").and_then(|v| v.as_bool()) { config.cron.catch_up_on_startup = v; } if let Some(v) = body.get("max_run_history").and_then(|v| v.as_u64()) { config.cron.max_run_history = u32::try_from(v).unwrap_or(u32::MAX); } if let Err(e) = config.save().await { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Failed to save config: {e}")})), ) .into_response(); } *state.config.lock() = config.clone(); Json(serde_json::json!({ "status": "ok", "enabled": config.cron.enabled, "catch_up_on_startup": config.cron.catch_up_on_startup, "max_run_history": config.cron.max_run_history, })) .into_response() } /// GET /api/integrations — list all integrations with status pub async fn handle_api_integrations( State(state): State, headers: HeaderMap, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } let config = state.config.lock().clone(); let entries = crate::integrations::registry::all_integrations(); let integrations: Vec = entries .iter() .map(|entry| { let status = (entry.status_fn)(&config); serde_json::json!({ "name": entry.name, "description": entry.description, "category": entry.category, "status": status, }) }) .collect(); Json(serde_json::json!({"integrations": integrations})).into_response() } /// GET /api/integrations/settings — return per-integration settings (enabled + category) pub async fn handle_api_integrations_settings( State(state): State, headers: HeaderMap, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } let config = state.config.lock().clone(); let entries = crate::integrations::registry::all_integrations(); let mut settings = serde_json::Map::new(); for entry in &entries { let status = (entry.status_fn)(&config); let enabled = matches!(status, crate::integrations::IntegrationStatus::Active); settings.insert( entry.name.to_string(), serde_json::json!({ "enabled": enabled, "category": entry.category, "status": status, }), ); } Json(serde_json::json!({"settings": settings})).into_response() } /// POST /api/doctor — run diagnostics pub async fn handle_api_doctor( State(state): State, headers: HeaderMap, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } let config = state.config.lock().clone(); let results = crate::doctor::diagnose(&config); let ok_count = results .iter() .filter(|r| r.severity == crate::doctor::Severity::Ok) .count(); let warn_count = results .iter() .filter(|r| r.severity == crate::doctor::Severity::Warn) .count(); let error_count = results .iter() .filter(|r| r.severity == crate::doctor::Severity::Error) .count(); Json(serde_json::json!({ "results": results, "summary": { "ok": ok_count, "warnings": warn_count, "errors": error_count, } })) .into_response() } /// GET /api/memory — list or search memory entries pub async fn handle_api_memory_list( State(state): State, headers: HeaderMap, Query(params): Query, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } if let Some(ref query) = params.query { // Search mode match state.mem.recall(query, 50, None).await { Ok(entries) => Json(serde_json::json!({"entries": entries})).into_response(), Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Memory recall failed: {e}")})), ) .into_response(), } } else { // List mode let category = params.category.as_deref().map(|cat| match cat { "core" => crate::memory::MemoryCategory::Core, "daily" => crate::memory::MemoryCategory::Daily, "conversation" => crate::memory::MemoryCategory::Conversation, other => crate::memory::MemoryCategory::Custom(other.to_string()), }); match state.mem.list(category.as_ref(), None).await { Ok(entries) => Json(serde_json::json!({"entries": entries})).into_response(), Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Memory list failed: {e}")})), ) .into_response(), } } } /// POST /api/memory — store a memory entry pub async fn handle_api_memory_store( State(state): State, headers: HeaderMap, Json(body): Json, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } let category = body .category .as_deref() .map(|cat| match cat { "core" => crate::memory::MemoryCategory::Core, "daily" => crate::memory::MemoryCategory::Daily, "conversation" => crate::memory::MemoryCategory::Conversation, other => crate::memory::MemoryCategory::Custom(other.to_string()), }) .unwrap_or(crate::memory::MemoryCategory::Core); match state .mem .store(&body.key, &body.content, category, None) .await { Ok(()) => Json(serde_json::json!({"status": "ok"})).into_response(), Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Memory store failed: {e}")})), ) .into_response(), } } /// DELETE /api/memory/:key — delete a memory entry pub async fn handle_api_memory_delete( State(state): State, headers: HeaderMap, Path(key): Path, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } match state.mem.forget(&key).await { Ok(deleted) => { Json(serde_json::json!({"status": "ok", "deleted": deleted})).into_response() } Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Memory forget failed: {e}")})), ) .into_response(), } } /// GET /api/cost — cost summary pub async fn handle_api_cost( State(state): State, headers: HeaderMap, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } if let Some(ref tracker) = state.cost_tracker { match tracker.get_summary() { Ok(summary) => Json(serde_json::json!({"cost": summary})).into_response(), Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Cost summary failed: {e}")})), ) .into_response(), } } else { Json(serde_json::json!({ "cost": { "session_cost_usd": 0.0, "daily_cost_usd": 0.0, "monthly_cost_usd": 0.0, "total_tokens": 0, "request_count": 0, "by_model": {}, } })) .into_response() } } /// GET /api/cli-tools — discovered CLI tools pub async fn handle_api_cli_tools( State(state): State, headers: HeaderMap, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } let tools = crate::tools::cli_discovery::discover_cli_tools(&[], &[]); Json(serde_json::json!({"cli_tools": tools})).into_response() } /// GET /api/health — component health snapshot pub async fn handle_api_health( State(state): State, headers: HeaderMap, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } let snapshot = crate::health::snapshot(); Json(serde_json::json!({"health": snapshot})).into_response() } // ── Helpers ───────────────────────────────────────────────────── fn is_masked_secret(value: &str) -> bool { value == MASKED_SECRET } fn mask_optional_secret(value: &mut Option) { if value.is_some() { *value = Some(MASKED_SECRET.to_string()); } } fn mask_required_secret(value: &mut String) { if !value.is_empty() { *value = MASKED_SECRET.to_string(); } } fn mask_vec_secrets(values: &mut [String]) { for value in values.iter_mut() { if !value.is_empty() { *value = MASKED_SECRET.to_string(); } } } #[allow(clippy::ref_option)] fn restore_optional_secret(value: &mut Option, current: &Option) { if value.as_deref().is_some_and(is_masked_secret) { *value = current.clone(); } } fn restore_required_secret(value: &mut String, current: &str) { if is_masked_secret(value) { *value = current.to_string(); } } fn restore_vec_secrets(values: &mut [String], current: &[String]) { for (idx, value) in values.iter_mut().enumerate() { if is_masked_secret(value) { if let Some(existing) = current.get(idx) { *value = existing.clone(); } } } } fn normalize_route_field(value: &str) -> String { value.trim().to_ascii_lowercase() } fn model_route_identity_matches( incoming: &crate::config::schema::ModelRouteConfig, current: &crate::config::schema::ModelRouteConfig, ) -> bool { normalize_route_field(&incoming.hint) == normalize_route_field(¤t.hint) && normalize_route_field(&incoming.provider) == normalize_route_field(¤t.provider) && normalize_route_field(&incoming.model) == normalize_route_field(¤t.model) } fn model_route_provider_model_matches( incoming: &crate::config::schema::ModelRouteConfig, current: &crate::config::schema::ModelRouteConfig, ) -> bool { normalize_route_field(&incoming.provider) == normalize_route_field(¤t.provider) && normalize_route_field(&incoming.model) == normalize_route_field(¤t.model) } fn embedding_route_identity_matches( incoming: &crate::config::schema::EmbeddingRouteConfig, current: &crate::config::schema::EmbeddingRouteConfig, ) -> bool { normalize_route_field(&incoming.hint) == normalize_route_field(¤t.hint) && normalize_route_field(&incoming.provider) == normalize_route_field(¤t.provider) && normalize_route_field(&incoming.model) == normalize_route_field(¤t.model) } fn embedding_route_provider_model_matches( incoming: &crate::config::schema::EmbeddingRouteConfig, current: &crate::config::schema::EmbeddingRouteConfig, ) -> bool { normalize_route_field(&incoming.provider) == normalize_route_field(¤t.provider) && normalize_route_field(&incoming.model) == normalize_route_field(¤t.model) } fn restore_model_route_api_keys( incoming: &mut [crate::config::schema::ModelRouteConfig], current: &[crate::config::schema::ModelRouteConfig], ) { let mut used_current = vec![false; current.len()]; for incoming_route in incoming { if !incoming_route .api_key .as_deref() .is_some_and(is_masked_secret) { continue; } let exact_match_idx = current .iter() .enumerate() .find(|(idx, current_route)| { !used_current[*idx] && model_route_identity_matches(incoming_route, current_route) }) .map(|(idx, _)| idx); let match_idx = exact_match_idx.or_else(|| { current .iter() .enumerate() .find(|(idx, current_route)| { !used_current[*idx] && model_route_provider_model_matches(incoming_route, current_route) }) .map(|(idx, _)| idx) }); if let Some(idx) = match_idx { used_current[idx] = true; incoming_route.api_key = current[idx].api_key.clone(); } else { // Never persist UI placeholders to disk when no safe restore target exists. incoming_route.api_key = None; } } } fn restore_embedding_route_api_keys( incoming: &mut [crate::config::schema::EmbeddingRouteConfig], current: &[crate::config::schema::EmbeddingRouteConfig], ) { let mut used_current = vec![false; current.len()]; for incoming_route in incoming { if !incoming_route .api_key .as_deref() .is_some_and(is_masked_secret) { continue; } let exact_match_idx = current .iter() .enumerate() .find(|(idx, current_route)| { !used_current[*idx] && embedding_route_identity_matches(incoming_route, current_route) }) .map(|(idx, _)| idx); let match_idx = exact_match_idx.or_else(|| { current .iter() .enumerate() .find(|(idx, current_route)| { !used_current[*idx] && embedding_route_provider_model_matches(incoming_route, current_route) }) .map(|(idx, _)| idx) }); if let Some(idx) = match_idx { used_current[idx] = true; incoming_route.api_key = current[idx].api_key.clone(); } else { // Never persist UI placeholders to disk when no safe restore target exists. incoming_route.api_key = None; } } } fn mask_sensitive_fields(config: &crate::config::Config) -> crate::config::Config { let mut masked = config.clone(); mask_optional_secret(&mut masked.api_key); mask_vec_secrets(&mut masked.reliability.api_keys); mask_vec_secrets(&mut masked.gateway.paired_tokens); mask_optional_secret(&mut masked.composio.api_key); mask_optional_secret(&mut masked.browser.computer_use.api_key); mask_optional_secret(&mut masked.web_search.brave_api_key); mask_optional_secret(&mut masked.storage.provider.config.db_url); mask_optional_secret(&mut masked.memory.qdrant.api_key); if let Some(cloudflare) = masked.tunnel.cloudflare.as_mut() { mask_required_secret(&mut cloudflare.token); } if let Some(ngrok) = masked.tunnel.ngrok.as_mut() { mask_required_secret(&mut ngrok.auth_token); } for agent in masked.agents.values_mut() { mask_optional_secret(&mut agent.api_key); } for route in &mut masked.model_routes { mask_optional_secret(&mut route.api_key); } for route in &mut masked.embedding_routes { mask_optional_secret(&mut route.api_key); } if let Some(telegram) = masked.channels_config.telegram.as_mut() { mask_required_secret(&mut telegram.bot_token); } if let Some(discord) = masked.channels_config.discord.as_mut() { mask_required_secret(&mut discord.bot_token); } if let Some(slack) = masked.channels_config.slack.as_mut() { mask_required_secret(&mut slack.bot_token); mask_optional_secret(&mut slack.app_token); } if let Some(mattermost) = masked.channels_config.mattermost.as_mut() { mask_required_secret(&mut mattermost.bot_token); } if let Some(webhook) = masked.channels_config.webhook.as_mut() { mask_optional_secret(&mut webhook.secret); } if let Some(matrix) = masked.channels_config.matrix.as_mut() { mask_required_secret(&mut matrix.access_token); } if let Some(whatsapp) = masked.channels_config.whatsapp.as_mut() { mask_optional_secret(&mut whatsapp.access_token); mask_optional_secret(&mut whatsapp.app_secret); mask_optional_secret(&mut whatsapp.verify_token); } if let Some(linq) = masked.channels_config.linq.as_mut() { mask_required_secret(&mut linq.api_token); mask_optional_secret(&mut linq.signing_secret); } if let Some(nextcloud) = masked.channels_config.nextcloud_talk.as_mut() { mask_required_secret(&mut nextcloud.app_token); mask_optional_secret(&mut nextcloud.webhook_secret); } if let Some(wati) = masked.channels_config.wati.as_mut() { mask_required_secret(&mut wati.api_token); } if let Some(irc) = masked.channels_config.irc.as_mut() { mask_optional_secret(&mut irc.server_password); mask_optional_secret(&mut irc.nickserv_password); mask_optional_secret(&mut irc.sasl_password); } if let Some(lark) = masked.channels_config.lark.as_mut() { mask_required_secret(&mut lark.app_secret); mask_optional_secret(&mut lark.encrypt_key); mask_optional_secret(&mut lark.verification_token); } if let Some(feishu) = masked.channels_config.feishu.as_mut() { mask_required_secret(&mut feishu.app_secret); mask_optional_secret(&mut feishu.encrypt_key); mask_optional_secret(&mut feishu.verification_token); } if let Some(dingtalk) = masked.channels_config.dingtalk.as_mut() { mask_required_secret(&mut dingtalk.client_secret); } if let Some(qq) = masked.channels_config.qq.as_mut() { mask_required_secret(&mut qq.app_secret); } #[cfg(feature = "channel-nostr")] if let Some(nostr) = masked.channels_config.nostr.as_mut() { mask_required_secret(&mut nostr.private_key); } if let Some(clawdtalk) = masked.channels_config.clawdtalk.as_mut() { mask_required_secret(&mut clawdtalk.api_key); mask_optional_secret(&mut clawdtalk.webhook_secret); } if let Some(email) = masked.channels_config.email.as_mut() { mask_required_secret(&mut email.password); } masked } fn restore_masked_sensitive_fields( incoming: &mut crate::config::Config, current: &crate::config::Config, ) { restore_optional_secret(&mut incoming.api_key, ¤t.api_key); restore_vec_secrets( &mut incoming.gateway.paired_tokens, ¤t.gateway.paired_tokens, ); restore_vec_secrets( &mut incoming.reliability.api_keys, ¤t.reliability.api_keys, ); restore_optional_secret(&mut incoming.composio.api_key, ¤t.composio.api_key); restore_optional_secret( &mut incoming.browser.computer_use.api_key, ¤t.browser.computer_use.api_key, ); restore_optional_secret( &mut incoming.web_search.brave_api_key, ¤t.web_search.brave_api_key, ); restore_optional_secret( &mut incoming.storage.provider.config.db_url, ¤t.storage.provider.config.db_url, ); restore_optional_secret( &mut incoming.memory.qdrant.api_key, ¤t.memory.qdrant.api_key, ); if let (Some(incoming_tunnel), Some(current_tunnel)) = ( incoming.tunnel.cloudflare.as_mut(), current.tunnel.cloudflare.as_ref(), ) { restore_required_secret(&mut incoming_tunnel.token, ¤t_tunnel.token); } if let (Some(incoming_tunnel), Some(current_tunnel)) = ( incoming.tunnel.ngrok.as_mut(), current.tunnel.ngrok.as_ref(), ) { restore_required_secret(&mut incoming_tunnel.auth_token, ¤t_tunnel.auth_token); } for (name, agent) in &mut incoming.agents { if let Some(current_agent) = current.agents.get(name) { restore_optional_secret(&mut agent.api_key, ¤t_agent.api_key); } } restore_model_route_api_keys(&mut incoming.model_routes, ¤t.model_routes); restore_embedding_route_api_keys(&mut incoming.embedding_routes, ¤t.embedding_routes); if let (Some(incoming_ch), Some(current_ch)) = ( incoming.channels_config.telegram.as_mut(), current.channels_config.telegram.as_ref(), ) { restore_required_secret(&mut incoming_ch.bot_token, ¤t_ch.bot_token); } if let (Some(incoming_ch), Some(current_ch)) = ( incoming.channels_config.discord.as_mut(), current.channels_config.discord.as_ref(), ) { restore_required_secret(&mut incoming_ch.bot_token, ¤t_ch.bot_token); } if let (Some(incoming_ch), Some(current_ch)) = ( incoming.channels_config.slack.as_mut(), current.channels_config.slack.as_ref(), ) { restore_required_secret(&mut incoming_ch.bot_token, ¤t_ch.bot_token); restore_optional_secret(&mut incoming_ch.app_token, ¤t_ch.app_token); } if let (Some(incoming_ch), Some(current_ch)) = ( incoming.channels_config.mattermost.as_mut(), current.channels_config.mattermost.as_ref(), ) { restore_required_secret(&mut incoming_ch.bot_token, ¤t_ch.bot_token); } if let (Some(incoming_ch), Some(current_ch)) = ( incoming.channels_config.webhook.as_mut(), current.channels_config.webhook.as_ref(), ) { restore_optional_secret(&mut incoming_ch.secret, ¤t_ch.secret); } if let (Some(incoming_ch), Some(current_ch)) = ( incoming.channels_config.matrix.as_mut(), current.channels_config.matrix.as_ref(), ) { restore_required_secret(&mut incoming_ch.access_token, ¤t_ch.access_token); } if let (Some(incoming_ch), Some(current_ch)) = ( incoming.channels_config.whatsapp.as_mut(), current.channels_config.whatsapp.as_ref(), ) { restore_optional_secret(&mut incoming_ch.access_token, ¤t_ch.access_token); restore_optional_secret(&mut incoming_ch.app_secret, ¤t_ch.app_secret); restore_optional_secret(&mut incoming_ch.verify_token, ¤t_ch.verify_token); } if let (Some(incoming_ch), Some(current_ch)) = ( incoming.channels_config.linq.as_mut(), current.channels_config.linq.as_ref(), ) { restore_required_secret(&mut incoming_ch.api_token, ¤t_ch.api_token); restore_optional_secret(&mut incoming_ch.signing_secret, ¤t_ch.signing_secret); } if let (Some(incoming_ch), Some(current_ch)) = ( incoming.channels_config.nextcloud_talk.as_mut(), current.channels_config.nextcloud_talk.as_ref(), ) { restore_required_secret(&mut incoming_ch.app_token, ¤t_ch.app_token); restore_optional_secret(&mut incoming_ch.webhook_secret, ¤t_ch.webhook_secret); } if let (Some(incoming_ch), Some(current_ch)) = ( incoming.channels_config.wati.as_mut(), current.channels_config.wati.as_ref(), ) { restore_required_secret(&mut incoming_ch.api_token, ¤t_ch.api_token); } if let (Some(incoming_ch), Some(current_ch)) = ( incoming.channels_config.irc.as_mut(), current.channels_config.irc.as_ref(), ) { restore_optional_secret( &mut incoming_ch.server_password, ¤t_ch.server_password, ); restore_optional_secret( &mut incoming_ch.nickserv_password, ¤t_ch.nickserv_password, ); restore_optional_secret(&mut incoming_ch.sasl_password, ¤t_ch.sasl_password); } if let (Some(incoming_ch), Some(current_ch)) = ( incoming.channels_config.lark.as_mut(), current.channels_config.lark.as_ref(), ) { restore_required_secret(&mut incoming_ch.app_secret, ¤t_ch.app_secret); restore_optional_secret(&mut incoming_ch.encrypt_key, ¤t_ch.encrypt_key); restore_optional_secret( &mut incoming_ch.verification_token, ¤t_ch.verification_token, ); } if let (Some(incoming_ch), Some(current_ch)) = ( incoming.channels_config.feishu.as_mut(), current.channels_config.feishu.as_ref(), ) { restore_required_secret(&mut incoming_ch.app_secret, ¤t_ch.app_secret); restore_optional_secret(&mut incoming_ch.encrypt_key, ¤t_ch.encrypt_key); restore_optional_secret( &mut incoming_ch.verification_token, ¤t_ch.verification_token, ); } if let (Some(incoming_ch), Some(current_ch)) = ( incoming.channels_config.dingtalk.as_mut(), current.channels_config.dingtalk.as_ref(), ) { restore_required_secret(&mut incoming_ch.client_secret, ¤t_ch.client_secret); } if let (Some(incoming_ch), Some(current_ch)) = ( incoming.channels_config.qq.as_mut(), current.channels_config.qq.as_ref(), ) { restore_required_secret(&mut incoming_ch.app_secret, ¤t_ch.app_secret); } #[cfg(feature = "channel-nostr")] if let (Some(incoming_ch), Some(current_ch)) = ( incoming.channels_config.nostr.as_mut(), current.channels_config.nostr.as_ref(), ) { restore_required_secret(&mut incoming_ch.private_key, ¤t_ch.private_key); } if let (Some(incoming_ch), Some(current_ch)) = ( incoming.channels_config.clawdtalk.as_mut(), current.channels_config.clawdtalk.as_ref(), ) { restore_required_secret(&mut incoming_ch.api_key, ¤t_ch.api_key); restore_optional_secret(&mut incoming_ch.webhook_secret, ¤t_ch.webhook_secret); } if let (Some(incoming_ch), Some(current_ch)) = ( incoming.channels_config.email.as_mut(), current.channels_config.email.as_ref(), ) { restore_required_secret(&mut incoming_ch.password, ¤t_ch.password); } } fn hydrate_config_for_save( mut incoming: crate::config::Config, current: &crate::config::Config, ) -> crate::config::Config { restore_masked_sensitive_fields(&mut incoming, current); // These are runtime-computed fields skipped from TOML serialization. incoming.config_path = current.config_path.clone(); incoming.workspace_dir = current.workspace_dir.clone(); incoming } // ── Session API handlers ───────────────────────────────────────── /// GET /api/sessions — list gateway sessions pub async fn handle_api_sessions_list( State(state): State, headers: HeaderMap, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } let Some(ref backend) = state.session_backend else { return Json(serde_json::json!({ "sessions": [], "message": "Session persistence is disabled" })) .into_response(); }; let all_metadata = backend.list_sessions_with_metadata(); let gw_sessions: Vec = all_metadata .into_iter() .filter_map(|meta| { let session_id = meta.key.strip_prefix("gw_")?; Some(serde_json::json!({ "session_id": session_id, "created_at": meta.created_at.to_rfc3339(), "last_activity": meta.last_activity.to_rfc3339(), "message_count": meta.message_count, })) }) .collect(); Json(serde_json::json!({ "sessions": gw_sessions })).into_response() } /// DELETE /api/sessions/{id} — delete a gateway session pub async fn handle_api_session_delete( State(state): State, headers: HeaderMap, Path(id): Path, ) -> impl IntoResponse { if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } let Some(ref backend) = state.session_backend else { return ( StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Session persistence is disabled"})), ) .into_response(); }; let session_key = format!("gw_{id}"); match backend.delete_session(&session_key) { Ok(true) => Json(serde_json::json!({"deleted": true, "session_id": id})).into_response(), Ok(false) => ( StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Session not found"})), ) .into_response(), Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Failed to delete session: {e}")})), ) .into_response(), } } #[cfg(test)] mod tests { use super::*; use crate::gateway::{nodes, AppState, GatewayRateLimiter, IdempotencyStore}; use crate::memory::{Memory, MemoryCategory, MemoryEntry}; use crate::providers::Provider; use crate::security::pairing::PairingGuard; use async_trait::async_trait; use axum::response::IntoResponse; use http_body_util::BodyExt; use parking_lot::Mutex; use std::sync::Arc; use std::time::Duration; struct MockMemory; #[async_trait] impl Memory for MockMemory { fn name(&self) -> &str { "mock" } async fn store( &self, _key: &str, _content: &str, _category: MemoryCategory, _session_id: Option<&str>, ) -> anyhow::Result<()> { Ok(()) } async fn recall( &self, _query: &str, _limit: usize, _session_id: Option<&str>, ) -> anyhow::Result> { Ok(Vec::new()) } async fn get(&self, _key: &str) -> anyhow::Result> { Ok(None) } async fn list( &self, _category: Option<&MemoryCategory>, _session_id: Option<&str>, ) -> anyhow::Result> { Ok(Vec::new()) } async fn forget(&self, _key: &str) -> anyhow::Result { Ok(false) } async fn count(&self) -> anyhow::Result { Ok(0) } async fn health_check(&self) -> bool { true } } struct MockProvider; #[async_trait] impl Provider for MockProvider { async fn chat_with_system( &self, _system_prompt: Option<&str>, _message: &str, _model: &str, _temperature: f64, ) -> anyhow::Result { Ok("ok".to_string()) } } fn test_state(config: crate::config::Config) -> AppState { AppState { config: Arc::new(Mutex::new(config)), provider: Arc::new(MockProvider), model: "test-model".into(), temperature: 0.0, mem: Arc::new(MockMemory), auto_save: false, webhook_secret_hash: None, pairing: Arc::new(PairingGuard::new(false, &[])), trust_forwarded_headers: false, rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100, 100)), idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300), 1000)), whatsapp: None, whatsapp_app_secret: None, linq: None, linq_signing_secret: None, nextcloud_talk: None, nextcloud_talk_webhook_secret: None, wati: None, observer: Arc::new(crate::observability::NoopObserver), tools_registry: Arc::new(Vec::new()), cost_tracker: None, event_tx: tokio::sync::broadcast::channel(16).0, shutdown_tx: tokio::sync::watch::channel(false).0, node_registry: Arc::new(nodes::NodeRegistry::new(16)), session_backend: None, device_registry: None, pending_pairings: None, } } async fn response_json(response: axum::response::Response) -> serde_json::Value { let body = response .into_body() .collect() .await .expect("response body") .to_bytes(); serde_json::from_slice(&body).expect("valid json response") } #[test] fn masking_keeps_toml_valid_and_preserves_api_keys_type() { let mut cfg = crate::config::Config::default(); cfg.api_key = Some("sk-live-123".to_string()); cfg.reliability.api_keys = vec!["rk-1".to_string(), "rk-2".to_string()]; cfg.gateway.paired_tokens = vec!["pair-token-1".to_string()]; cfg.tunnel.cloudflare = Some(crate::config::schema::CloudflareTunnelConfig { token: "cf-token".to_string(), }); cfg.memory.qdrant.api_key = Some("qdrant-key".to_string()); cfg.channels_config.wati = Some(crate::config::schema::WatiConfig { api_token: "wati-token".to_string(), api_url: "https://live-mt-server.wati.io".to_string(), tenant_id: None, allowed_numbers: vec![], }); cfg.channels_config.feishu = Some(crate::config::schema::FeishuConfig { app_id: "cli_aabbcc".to_string(), app_secret: "feishu-secret".to_string(), encrypt_key: Some("feishu-encrypt".to_string()), verification_token: Some("feishu-verify".to_string()), allowed_users: vec!["*".to_string()], receive_mode: crate::config::schema::LarkReceiveMode::Websocket, port: None, }); cfg.channels_config.email = Some(crate::channels::email_channel::EmailConfig { imap_host: "imap.example.com".to_string(), imap_port: 993, imap_folder: "INBOX".to_string(), smtp_host: "smtp.example.com".to_string(), smtp_port: 465, smtp_tls: true, username: "agent@example.com".to_string(), password: "email-password-secret".to_string(), from_address: "agent@example.com".to_string(), idle_timeout_secs: 1740, allowed_senders: vec!["*".to_string()], default_subject: "ZeroClaw Message".to_string(), }); cfg.model_routes = vec![crate::config::schema::ModelRouteConfig { hint: "reasoning".to_string(), provider: "openrouter".to_string(), model: "anthropic/claude-sonnet-4.6".to_string(), api_key: Some("route-model-key".to_string()), }]; cfg.embedding_routes = vec![crate::config::schema::EmbeddingRouteConfig { hint: "semantic".to_string(), provider: "openai".to_string(), model: "text-embedding-3-small".to_string(), dimensions: Some(1536), api_key: Some("route-embed-key".to_string()), }]; let masked = mask_sensitive_fields(&cfg); let toml = toml::to_string_pretty(&masked).expect("masked config should serialize"); let parsed: crate::config::Config = toml::from_str(&toml).expect("masked config should remain valid TOML for Config"); assert_eq!(parsed.api_key.as_deref(), Some(MASKED_SECRET)); assert_eq!( parsed.reliability.api_keys, vec![MASKED_SECRET.to_string(), MASKED_SECRET.to_string()] ); assert_eq!( parsed.gateway.paired_tokens, vec![MASKED_SECRET.to_string()] ); assert_eq!( parsed.tunnel.cloudflare.as_ref().map(|v| v.token.as_str()), Some(MASKED_SECRET) ); assert_eq!( parsed .channels_config .wati .as_ref() .map(|v| v.api_token.as_str()), Some(MASKED_SECRET) ); assert_eq!(parsed.memory.qdrant.api_key.as_deref(), Some(MASKED_SECRET)); assert_eq!( parsed .channels_config .feishu .as_ref() .map(|v| v.app_secret.as_str()), Some(MASKED_SECRET) ); assert_eq!( parsed .channels_config .feishu .as_ref() .and_then(|v| v.encrypt_key.as_deref()), Some(MASKED_SECRET) ); assert_eq!( parsed .channels_config .feishu .as_ref() .and_then(|v| v.verification_token.as_deref()), Some(MASKED_SECRET) ); assert_eq!( parsed .model_routes .first() .and_then(|v| v.api_key.as_deref()), Some(MASKED_SECRET) ); assert_eq!( parsed .embedding_routes .first() .and_then(|v| v.api_key.as_deref()), Some(MASKED_SECRET) ); assert_eq!( parsed .channels_config .email .as_ref() .map(|v| v.password.as_str()), Some(MASKED_SECRET) ); } #[test] fn hydrate_config_for_save_restores_masked_secrets_and_paths() { let mut current = crate::config::Config::default(); current.config_path = std::path::PathBuf::from("/tmp/current/config.toml"); current.workspace_dir = std::path::PathBuf::from("/tmp/current/workspace"); current.api_key = Some("real-key".to_string()); current.reliability.api_keys = vec!["r1".to_string(), "r2".to_string()]; current.gateway.paired_tokens = vec!["pair-1".to_string(), "pair-2".to_string()]; current.tunnel.cloudflare = Some(crate::config::schema::CloudflareTunnelConfig { token: "cf-token-real".to_string(), }); current.tunnel.ngrok = Some(crate::config::schema::NgrokTunnelConfig { auth_token: "ngrok-token-real".to_string(), domain: None, }); current.memory.qdrant.api_key = Some("qdrant-real".to_string()); current.channels_config.wati = Some(crate::config::schema::WatiConfig { api_token: "wati-real".to_string(), api_url: "https://live-mt-server.wati.io".to_string(), tenant_id: None, allowed_numbers: vec![], }); current.channels_config.feishu = Some(crate::config::schema::FeishuConfig { app_id: "cli_current".to_string(), app_secret: "feishu-secret-real".to_string(), encrypt_key: Some("feishu-encrypt-real".to_string()), verification_token: Some("feishu-verify-real".to_string()), allowed_users: vec!["*".to_string()], receive_mode: crate::config::schema::LarkReceiveMode::Websocket, port: None, }); current.channels_config.email = Some(crate::channels::email_channel::EmailConfig { imap_host: "imap.example.com".to_string(), imap_port: 993, imap_folder: "INBOX".to_string(), smtp_host: "smtp.example.com".to_string(), smtp_port: 465, smtp_tls: true, username: "agent@example.com".to_string(), password: "email-password-real".to_string(), from_address: "agent@example.com".to_string(), idle_timeout_secs: 1740, allowed_senders: vec!["*".to_string()], default_subject: "ZeroClaw Message".to_string(), }); current.model_routes = vec![ crate::config::schema::ModelRouteConfig { hint: "reasoning".to_string(), provider: "openrouter".to_string(), model: "anthropic/claude-sonnet-4.6".to_string(), api_key: Some("route-model-key-1".to_string()), }, crate::config::schema::ModelRouteConfig { hint: "fast".to_string(), provider: "openrouter".to_string(), model: "openai/gpt-4.1-mini".to_string(), api_key: Some("route-model-key-2".to_string()), }, ]; current.embedding_routes = vec![ crate::config::schema::EmbeddingRouteConfig { hint: "semantic".to_string(), provider: "openai".to_string(), model: "text-embedding-3-small".to_string(), dimensions: Some(1536), api_key: Some("route-embed-key-1".to_string()), }, crate::config::schema::EmbeddingRouteConfig { hint: "archive".to_string(), provider: "custom:https://emb.example.com/v1".to_string(), model: "bge-m3".to_string(), dimensions: Some(1024), api_key: Some("route-embed-key-2".to_string()), }, ]; let mut incoming = mask_sensitive_fields(¤t); incoming.default_model = Some("gpt-4.1-mini".to_string()); // Simulate UI changing only one key and keeping the first masked. incoming.reliability.api_keys = vec![MASKED_SECRET.to_string(), "r2-new".to_string()]; incoming.gateway.paired_tokens = vec![MASKED_SECRET.to_string(), "pair-2-new".to_string()]; if let Some(cloudflare) = incoming.tunnel.cloudflare.as_mut() { cloudflare.token = MASKED_SECRET.to_string(); } if let Some(ngrok) = incoming.tunnel.ngrok.as_mut() { ngrok.auth_token = MASKED_SECRET.to_string(); } incoming.memory.qdrant.api_key = Some(MASKED_SECRET.to_string()); if let Some(wati) = incoming.channels_config.wati.as_mut() { wati.api_token = MASKED_SECRET.to_string(); } if let Some(feishu) = incoming.channels_config.feishu.as_mut() { feishu.app_secret = MASKED_SECRET.to_string(); feishu.encrypt_key = Some(MASKED_SECRET.to_string()); feishu.verification_token = Some("feishu-verify-new".to_string()); } if let Some(email) = incoming.channels_config.email.as_mut() { email.password = MASKED_SECRET.to_string(); } incoming.model_routes[1].api_key = Some("route-model-key-2-new".to_string()); incoming.embedding_routes[1].api_key = Some("route-embed-key-2-new".to_string()); let hydrated = hydrate_config_for_save(incoming, ¤t); assert_eq!(hydrated.config_path, current.config_path); assert_eq!(hydrated.workspace_dir, current.workspace_dir); assert_eq!(hydrated.api_key, current.api_key); assert_eq!(hydrated.default_model.as_deref(), Some("gpt-4.1-mini")); assert_eq!( hydrated.reliability.api_keys, vec!["r1".to_string(), "r2-new".to_string()] ); assert_eq!( hydrated.gateway.paired_tokens, vec!["pair-1".to_string(), "pair-2-new".to_string()] ); assert_eq!( hydrated .tunnel .cloudflare .as_ref() .map(|v| v.token.as_str()), Some("cf-token-real") ); assert_eq!( hydrated .tunnel .ngrok .as_ref() .map(|v| v.auth_token.as_str()), Some("ngrok-token-real") ); assert_eq!( hydrated.memory.qdrant.api_key.as_deref(), Some("qdrant-real") ); assert_eq!( hydrated .channels_config .wati .as_ref() .map(|v| v.api_token.as_str()), Some("wati-real") ); assert_eq!( hydrated .channels_config .feishu .as_ref() .map(|v| v.app_secret.as_str()), Some("feishu-secret-real") ); assert_eq!( hydrated .channels_config .feishu .as_ref() .and_then(|v| v.encrypt_key.as_deref()), Some("feishu-encrypt-real") ); assert_eq!( hydrated .channels_config .feishu .as_ref() .and_then(|v| v.verification_token.as_deref()), Some("feishu-verify-new") ); assert_eq!( hydrated.model_routes[0].api_key.as_deref(), Some("route-model-key-1") ); assert_eq!( hydrated.model_routes[1].api_key.as_deref(), Some("route-model-key-2-new") ); assert_eq!( hydrated.embedding_routes[0].api_key.as_deref(), Some("route-embed-key-1") ); assert_eq!( hydrated.embedding_routes[1].api_key.as_deref(), Some("route-embed-key-2-new") ); assert_eq!( hydrated .channels_config .email .as_ref() .map(|v| v.password.as_str()), Some("email-password-real") ); } #[test] fn hydrate_config_for_save_restores_route_keys_by_identity_and_clears_unmatched_masks() { let mut current = crate::config::Config::default(); current.model_routes = vec![ crate::config::schema::ModelRouteConfig { hint: "reasoning".to_string(), provider: "openrouter".to_string(), model: "anthropic/claude-sonnet-4.6".to_string(), api_key: Some("route-model-key-1".to_string()), }, crate::config::schema::ModelRouteConfig { hint: "fast".to_string(), provider: "openrouter".to_string(), model: "openai/gpt-4.1-mini".to_string(), api_key: Some("route-model-key-2".to_string()), }, ]; current.embedding_routes = vec![ crate::config::schema::EmbeddingRouteConfig { hint: "semantic".to_string(), provider: "openai".to_string(), model: "text-embedding-3-small".to_string(), dimensions: Some(1536), api_key: Some("route-embed-key-1".to_string()), }, crate::config::schema::EmbeddingRouteConfig { hint: "archive".to_string(), provider: "custom:https://emb.example.com/v1".to_string(), model: "bge-m3".to_string(), dimensions: Some(1024), api_key: Some("route-embed-key-2".to_string()), }, ]; let mut incoming = mask_sensitive_fields(¤t); incoming.model_routes.swap(0, 1); incoming.embedding_routes.swap(0, 1); incoming .model_routes .push(crate::config::schema::ModelRouteConfig { hint: "new".to_string(), provider: "openai".to_string(), model: "gpt-4.1".to_string(), api_key: Some(MASKED_SECRET.to_string()), }); incoming .embedding_routes .push(crate::config::schema::EmbeddingRouteConfig { hint: "new-embed".to_string(), provider: "custom:https://emb2.example.com/v1".to_string(), model: "bge-small".to_string(), dimensions: Some(768), api_key: Some(MASKED_SECRET.to_string()), }); let hydrated = hydrate_config_for_save(incoming, ¤t); assert_eq!( hydrated.model_routes[0].api_key.as_deref(), Some("route-model-key-2") ); assert_eq!( hydrated.model_routes[1].api_key.as_deref(), Some("route-model-key-1") ); assert_eq!(hydrated.model_routes[2].api_key, None); assert_eq!( hydrated.embedding_routes[0].api_key.as_deref(), Some("route-embed-key-2") ); assert_eq!( hydrated.embedding_routes[1].api_key.as_deref(), Some("route-embed-key-1") ); assert_eq!(hydrated.embedding_routes[2].api_key, None); assert!(hydrated .model_routes .iter() .all(|route| route.api_key.as_deref() != Some(MASKED_SECRET))); assert!(hydrated .embedding_routes .iter() .all(|route| route.api_key.as_deref() != Some(MASKED_SECRET))); } #[tokio::test] async fn cron_api_shell_roundtrip_includes_delivery() { let tmp = tempfile::TempDir::new().unwrap(); let config = crate::config::Config { workspace_dir: tmp.path().join("workspace"), config_path: tmp.path().join("config.toml"), ..crate::config::Config::default() }; std::fs::create_dir_all(&config.workspace_dir).unwrap(); let state = test_state(config); let add_response = handle_api_cron_add( State(state.clone()), HeaderMap::new(), Json( serde_json::from_value::(serde_json::json!({ "name": "test-job", "schedule": "*/5 * * * *", "command": "echo hello", "delivery": { "mode": "announce", "channel": "discord", "to": "1234567890", "best_effort": true } })) .expect("body should deserialize"), ), ) .await .into_response(); let add_json = response_json(add_response).await; assert_eq!(add_json["status"], "ok"); assert_eq!(add_json["job"]["delivery"]["mode"], "announce"); assert_eq!(add_json["job"]["delivery"]["channel"], "discord"); assert_eq!(add_json["job"]["delivery"]["to"], "1234567890"); let list_response = handle_api_cron_list(State(state), HeaderMap::new()) .await .into_response(); let list_json = response_json(list_response).await; let jobs = list_json["jobs"].as_array().expect("jobs array"); assert_eq!(jobs.len(), 1); assert_eq!(jobs[0]["delivery"]["mode"], "announce"); assert_eq!(jobs[0]["delivery"]["channel"], "discord"); assert_eq!(jobs[0]["delivery"]["to"], "1234567890"); } #[tokio::test] async fn cron_api_accepts_agent_jobs() { let tmp = tempfile::TempDir::new().unwrap(); let config = crate::config::Config { workspace_dir: tmp.path().join("workspace"), config_path: tmp.path().join("config.toml"), ..crate::config::Config::default() }; std::fs::create_dir_all(&config.workspace_dir).unwrap(); let state = test_state(config); let response = handle_api_cron_add( State(state.clone()), HeaderMap::new(), Json( serde_json::from_value::(serde_json::json!({ "name": "agent-job", "schedule": "*/5 * * * *", "job_type": "agent", "command": "ignored shell command", "prompt": "summarize the latest logs" })) .expect("body should deserialize"), ), ) .await .into_response(); let json = response_json(response).await; assert_eq!(json["status"], "ok"); let config = state.config.lock().clone(); let jobs = crate::cron::list_jobs(&config).unwrap(); assert_eq!(jobs.len(), 1); assert_eq!(jobs[0].job_type, crate::cron::JobType::Agent); assert_eq!(jobs[0].prompt.as_deref(), Some("summarize the latest logs")); } #[tokio::test] async fn cron_api_rejects_announce_delivery_without_target() { let tmp = tempfile::TempDir::new().unwrap(); let config = crate::config::Config { workspace_dir: tmp.path().join("workspace"), config_path: tmp.path().join("config.toml"), ..crate::config::Config::default() }; std::fs::create_dir_all(&config.workspace_dir).unwrap(); let state = test_state(config); let response = handle_api_cron_add( State(state.clone()), HeaderMap::new(), Json( serde_json::from_value::(serde_json::json!({ "name": "invalid-delivery-job", "schedule": "*/5 * * * *", "command": "echo hello", "delivery": { "mode": "announce", "channel": "discord" } })) .expect("body should deserialize"), ), ) .await .into_response(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let json = response_json(response).await; assert!(json["error"] .as_str() .unwrap_or_default() .contains("delivery.to is required")); let config = state.config.lock().clone(); assert!(crate::cron::list_jobs(&config).unwrap().is_empty()); } #[tokio::test] async fn cron_api_rejects_announce_delivery_with_unsupported_channel() { let tmp = tempfile::TempDir::new().unwrap(); let config = crate::config::Config { workspace_dir: tmp.path().join("workspace"), config_path: tmp.path().join("config.toml"), ..crate::config::Config::default() }; std::fs::create_dir_all(&config.workspace_dir).unwrap(); let state = test_state(config); let response = handle_api_cron_add( State(state.clone()), HeaderMap::new(), Json( serde_json::from_value::(serde_json::json!({ "name": "invalid-delivery-job", "schedule": "*/5 * * * *", "command": "echo hello", "delivery": { "mode": "announce", "channel": "email", "to": "alerts@example.com" } })) .expect("body should deserialize"), ), ) .await .into_response(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let json = response_json(response).await; assert!(json["error"] .as_str() .unwrap_or_default() .contains("unsupported delivery channel")); let config = state.config.lock().clone(); assert!(crate::cron::list_jobs(&config).unwrap().is_empty()); } }