zeroclaw/src/gateway/api.rs
Argenis b8ebe7bcd3
feat(gateway): add cron run history API and dashboard panel (#3440)
Add GET /api/cron/{id}/runs?limit=N endpoint that returns recent stored
runs for a cron job, with server-side limit clamping to 1-100 (default 20).

Frontend adds a CronRun type, API client function, and an expandable
run history panel on the Cron page showing status, timestamps, duration,
and output for each run, with loading, empty, error, and refresh states.

Closes #3299

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:06:44 -04:00

1490 lines
52 KiB
Rust

//! 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<serde_json::Value>)> {
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 <token>"
})),
))
}
}
// ── Query parameters ─────────────────────────────────────────────
#[derive(Deserialize)]
pub struct MemoryQuery {
pub query: Option<String>,
pub category: Option<String>,
}
#[derive(Deserialize)]
pub struct MemoryStoreBody {
pub key: String,
pub content: String,
pub category: Option<String>,
}
#[derive(Deserialize)]
pub struct CronRunsQuery {
pub limit: Option<u32>,
}
#[derive(Deserialize)]
pub struct CronAddBody {
pub name: Option<String>,
pub schedule: String,
pub command: String,
}
// ── Handlers ────────────────────────────────────────────────────
/// GET /api/status — system status overview
pub async fn handle_api_status(
State(state): State<AppState>,
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<AppState>,
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<AppState>,
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, &current_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<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let tools: Vec<serde_json::Value> = 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<AppState>,
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) => {
let jobs_json: Vec<serde_json::Value> = jobs
.iter()
.map(|job| {
serde_json::json!({
"id": job.id,
"name": job.name,
"command": job.command,
"next_run": job.next_run.to_rfc3339(),
"last_run": job.last_run.map(|t| t.to_rfc3339()),
"last_status": job.last_status,
"enabled": job.enabled,
})
})
.collect();
Json(serde_json::json!({"jobs": jobs_json})).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<AppState>,
headers: HeaderMap,
Json(body): Json<CronAddBody>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let config = state.config.lock().clone();
let schedule = crate::cron::Schedule::Cron {
expr: body.schedule,
tz: None,
};
match crate::cron::add_shell_job_with_approval(
&config,
body.name,
schedule,
&body.command,
false,
) {
Ok(job) => Json(serde_json::json!({
"status": "ok",
"job": {
"id": job.id,
"name": job.name,
"command": job.command,
"enabled": job.enabled,
}
}))
.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<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Query(params): Query<CronRunsQuery>,
) -> 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<serde_json::Value> = 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(),
}
}
/// DELETE /api/cron/:id — remove a cron job
pub async fn handle_api_cron_delete(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> 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/integrations — list all integrations with status
pub async fn handle_api_integrations(
State(state): State<AppState>,
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<serde_json::Value> = 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<AppState>,
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<AppState>,
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<AppState>,
headers: HeaderMap,
Query(params): Query<MemoryQuery>,
) -> 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<AppState>,
headers: HeaderMap,
Json(body): Json<MemoryStoreBody>,
) -> 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<AppState>,
headers: HeaderMap,
Path(key): Path<String>,
) -> 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<AppState>,
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<AppState>,
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<AppState>,
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<String>) {
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<String>, current: &Option<String>) {
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(&current.hint)
&& normalize_route_field(&incoming.provider) == normalize_route_field(&current.provider)
&& normalize_route_field(&incoming.model) == normalize_route_field(&current.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(&current.provider)
&& normalize_route_field(&incoming.model) == normalize_route_field(&current.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(&current.hint)
&& normalize_route_field(&incoming.provider) == normalize_route_field(&current.provider)
&& normalize_route_field(&incoming.model) == normalize_route_field(&current.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(&current.provider)
&& normalize_route_field(&incoming.model) == normalize_route_field(&current.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, &current.api_key);
restore_vec_secrets(
&mut incoming.gateway.paired_tokens,
&current.gateway.paired_tokens,
);
restore_vec_secrets(
&mut incoming.reliability.api_keys,
&current.reliability.api_keys,
);
restore_optional_secret(&mut incoming.composio.api_key, &current.composio.api_key);
restore_optional_secret(
&mut incoming.browser.computer_use.api_key,
&current.browser.computer_use.api_key,
);
restore_optional_secret(
&mut incoming.web_search.brave_api_key,
&current.web_search.brave_api_key,
);
restore_optional_secret(
&mut incoming.storage.provider.config.db_url,
&current.storage.provider.config.db_url,
);
restore_optional_secret(
&mut incoming.memory.qdrant.api_key,
&current.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, &current_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, &current_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, &current_agent.api_key);
}
}
restore_model_route_api_keys(&mut incoming.model_routes, &current.model_routes);
restore_embedding_route_api_keys(&mut incoming.embedding_routes, &current.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, &current_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, &current_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, &current_ch.bot_token);
restore_optional_secret(&mut incoming_ch.app_token, &current_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, &current_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, &current_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, &current_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, &current_ch.access_token);
restore_optional_secret(&mut incoming_ch.app_secret, &current_ch.app_secret);
restore_optional_secret(&mut incoming_ch.verify_token, &current_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, &current_ch.api_token);
restore_optional_secret(&mut incoming_ch.signing_secret, &current_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, &current_ch.app_token);
restore_optional_secret(&mut incoming_ch.webhook_secret, &current_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, &current_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,
&current_ch.server_password,
);
restore_optional_secret(
&mut incoming_ch.nickserv_password,
&current_ch.nickserv_password,
);
restore_optional_secret(&mut incoming_ch.sasl_password, &current_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, &current_ch.app_secret);
restore_optional_secret(&mut incoming_ch.encrypt_key, &current_ch.encrypt_key);
restore_optional_secret(
&mut incoming_ch.verification_token,
&current_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, &current_ch.app_secret);
restore_optional_secret(&mut incoming_ch.encrypt_key, &current_ch.encrypt_key);
restore_optional_secret(
&mut incoming_ch.verification_token,
&current_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, &current_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, &current_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, &current_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, &current_ch.api_key);
restore_optional_secret(&mut incoming_ch.webhook_secret, &current_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, &current_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
}
#[cfg(test)]
mod tests {
use super::*;
#[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(&current);
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, &current);
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(&current);
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, &current);
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)));
}
}