Skip to main content

roboticus_api/api/routes/
mod.rs

1mod admin;
2mod agent;
3mod channels;
4mod cron;
5mod health;
6mod interview;
7pub(crate) mod mcp;
8mod memory;
9mod sessions;
10mod skills;
11pub(crate) mod subagent_integrity;
12mod themes;
13pub(crate) use self::agent::execute_scheduled_agent_task;
14mod observability;
15mod subagents;
16mod traces;
17
18use std::collections::HashMap;
19use std::path::PathBuf;
20use std::sync::Arc;
21
22use axum::extract::DefaultBodyLimit;
23use axum::{
24    Router, middleware,
25    routing::{get, post, put},
26};
27use tokio::sync::RwLock;
28
29use crate::config_runtime::ConfigApplyStatus;
30use roboticus_agent::policy::PolicyEngine;
31use roboticus_agent::subagents::SubagentRegistry;
32use roboticus_browser::Browser;
33use roboticus_channels::a2a::A2aProtocol;
34use roboticus_channels::router::ChannelRouter;
35use roboticus_channels::telegram::TelegramAdapter;
36use roboticus_channels::whatsapp::WhatsAppAdapter;
37use roboticus_core::RoboticusConfig;
38use roboticus_core::personality::{self, OsIdentity, OsVoice};
39use roboticus_db::Database;
40use roboticus_llm::LlmService;
41use roboticus_llm::OAuthManager;
42use roboticus_llm::semantic_classifier::SemanticClassifier;
43use roboticus_plugin_sdk::registry::PluginRegistry;
44use roboticus_wallet::WalletService;
45
46use roboticus_agent::approvals::ApprovalManager;
47use roboticus_agent::capability::CapabilityRegistry;
48use roboticus_agent::obsidian::ObsidianVault;
49use roboticus_agent::tools::ToolRegistry;
50use roboticus_channels::discord::DiscordAdapter;
51use roboticus_channels::email::EmailAdapter;
52use roboticus_channels::media::MediaService;
53use roboticus_channels::signal::SignalAdapter;
54use roboticus_channels::voice::VoicePipeline;
55
56use crate::ws::EventBus;
57
58// ── JSON error response type ─────────────────────────────────
59
60/// A JSON-formatted API error response. All error paths in the API return
61/// `{"error": "<message>"}` with the appropriate HTTP status code.
62#[derive(Debug)]
63pub(crate) struct JsonError(pub axum::http::StatusCode, pub String);
64
65impl axum::response::IntoResponse for JsonError {
66    fn into_response(self) -> axum::response::Response {
67        let body = serde_json::json!({ "error": self.1 });
68        (self.0, axum::Json(body)).into_response()
69    }
70}
71
72impl From<(axum::http::StatusCode, String)> for JsonError {
73    fn from((status, msg): (axum::http::StatusCode, String)) -> Self {
74        Self(status, msg)
75    }
76}
77
78/// Shorthand for a 400 Bad Request JSON error.
79pub(crate) fn bad_request(msg: impl std::fmt::Display) -> JsonError {
80    JsonError(axum::http::StatusCode::BAD_REQUEST, msg.to_string())
81}
82
83/// Shorthand for a 404 Not Found JSON error.
84pub(crate) fn not_found(msg: impl std::fmt::Display) -> JsonError {
85    JsonError(axum::http::StatusCode::NOT_FOUND, msg.to_string())
86}
87
88// ── Helpers (used by submodules) ──────────────────────────────
89
90/// Sanitizes error messages before returning to clients (strip paths, internal details, cap length).
91///
92/// LIMITATIONS: This is a best-effort filter that strips known wrapper
93/// prefixes and truncates. It does NOT guarantee that internal details
94/// (file paths, SQL fragments, stack traces) are fully redacted. If a new
95/// error source leaks sensitive info, add its prefix to the stripping list
96/// below or, better, ensure the call site maps the error before it reaches
97/// this function.
98pub(crate) fn sanitize_error_message(msg: &str) -> String {
99    let sanitized = msg.lines().next().unwrap_or(msg);
100
101    let sanitized = sanitized
102        .trim_start_matches("Database(\"")
103        .trim_end_matches("\")")
104        .trim_start_matches("Wallet(\"")
105        .trim_end_matches("\")");
106
107    // Strip content after common internal-detail prefixes that may leak
108    // implementation specifics (connection strings, file paths, etc.).
109    let sensitive_prefixes = [
110        "at /", // stack trace file paths
111        "called `Result::unwrap()` on an `Err` value:",
112        "SQLITE_",                // raw SQLite error codes
113        "Connection refused",     // infra details
114        "constraint failed",      // SQLite constraint errors (leaks table/column names)
115        "no such table",          // SQLite schema details
116        "no such column",         // SQLite schema details
117        "UNIQUE constraint",      // SQLite constraint (leaks table.column)
118        "FOREIGN KEY constraint", // SQLite constraint
119        "NOT NULL constraint",    // SQLite constraint
120    ];
121    let sanitized = {
122        let mut s = sanitized.to_string();
123        for prefix in &sensitive_prefixes {
124            if let Some(pos) = s.find(prefix) {
125                s.truncate(pos);
126                s.push_str("[details redacted]");
127                break;
128            }
129        }
130        s
131    };
132
133    if sanitized.len() > 200 {
134        let boundary = sanitized
135            .char_indices()
136            .map(|(i, _)| i)
137            .take_while(|&i| i <= 200)
138            .last()
139            .unwrap_or(0);
140        format!("{}...", &sanitized[..boundary])
141    } else {
142        sanitized
143    }
144}
145
146/// Logs the full error and returns a JSON 500 error for API responses.
147pub(crate) fn internal_err(e: &impl std::fmt::Display) -> JsonError {
148    tracing::error!(error = %e, "request failed");
149    JsonError(
150        axum::http::StatusCode::INTERNAL_SERVER_ERROR,
151        sanitize_error_message(&e.to_string()),
152    )
153}
154
155// ── Input validation helpers ──────────────────────────────────
156
157/// Maximum allowed length for short identifier fields (agent_id, name, etc.).
158const MAX_SHORT_FIELD: usize = 256;
159/// Maximum allowed length for long text fields (description, content, etc.).
160const MAX_LONG_FIELD: usize = 4096;
161
162/// Validate a user-supplied string field: reject empty/whitespace-only, null bytes, and enforce length.
163pub(crate) fn validate_field(
164    field_name: &str,
165    value: &str,
166    max_len: usize,
167) -> Result<(), JsonError> {
168    if value.trim().is_empty() {
169        return Err(bad_request(format!("{field_name} must not be empty")));
170    }
171    if value.contains('\0') {
172        return Err(bad_request(format!(
173            "{field_name} must not contain null bytes"
174        )));
175    }
176    if value.len() > max_len {
177        return Err(bad_request(format!(
178            "{field_name} exceeds max length ({max_len})"
179        )));
180    }
181    Ok(())
182}
183
184/// Validate a short identifier field (agent_id, name, session_id, etc.).
185pub(crate) fn validate_short(field_name: &str, value: &str) -> Result<(), JsonError> {
186    validate_field(field_name, value, MAX_SHORT_FIELD)
187}
188
189/// Validate a long text field (description, content, etc.).
190pub(crate) fn validate_long(field_name: &str, value: &str) -> Result<(), JsonError> {
191    validate_field(field_name, value, MAX_LONG_FIELD)
192}
193
194/// Strip HTML tags from a string to prevent injection in stored values.
195pub(crate) fn sanitize_html(input: &str) -> String {
196    input
197        .replace('&', "&amp;")
198        .replace('<', "&lt;")
199        .replace('>', "&gt;")
200        .replace('"', "&quot;")
201        .replace('\'', "&#x27;")
202}
203
204// ── Pagination helpers ──────────────────────────────────────────
205
206/// Default maximum items per page for list endpoints.
207const DEFAULT_PAGE_SIZE: i64 = 200;
208/// Absolute maximum items per page (prevents memory abuse via huge limits).
209const MAX_PAGE_SIZE: i64 = 500;
210
211/// Shared pagination query parameters for list endpoints.
212#[derive(Debug, serde::Deserialize)]
213pub(crate) struct PaginationQuery {
214    pub limit: Option<i64>,
215    pub offset: Option<i64>,
216}
217
218impl PaginationQuery {
219    /// Returns (limit, offset) clamped to safe ranges.
220    pub fn resolve(&self) -> (i64, i64) {
221        let limit = self
222            .limit
223            .unwrap_or(DEFAULT_PAGE_SIZE)
224            .clamp(1, MAX_PAGE_SIZE);
225        let offset = self.offset.unwrap_or(0).max(0);
226        (limit, offset)
227    }
228}
229
230// ── Shared state and types ────────────────────────────────────
231
232/// Holds the composed personality text plus metadata for status display.
233#[derive(Debug, Clone)]
234pub struct PersonalityState {
235    pub os_text: String,
236    pub firmware_text: String,
237    pub identity: OsIdentity,
238    pub voice: OsVoice,
239}
240
241impl PersonalityState {
242    pub fn from_workspace(workspace: &std::path::Path) -> Self {
243        let os = personality::load_os(workspace);
244        let fw = personality::load_firmware(workspace);
245        let operator = personality::load_operator(workspace);
246        let directives = personality::load_directives(workspace);
247
248        let os_text =
249            personality::compose_identity_text(os.as_ref(), operator.as_ref(), directives.as_ref());
250        let firmware_text = personality::compose_firmware_text(fw.as_ref());
251
252        let (identity, voice) = match os {
253            Some(os) => (os.identity, os.voice),
254            None => (
255                OsIdentity {
256                    name: String::new(),
257                    version: "1.0".into(),
258                    generated_by: "none".into(),
259                },
260                OsVoice::default(),
261            ),
262        };
263
264        Self {
265            os_text,
266            firmware_text,
267            identity,
268            voice,
269        }
270    }
271
272    pub fn empty() -> Self {
273        Self {
274            os_text: String::new(),
275            firmware_text: String::new(),
276            identity: OsIdentity {
277                name: String::new(),
278                version: "1.0".into(),
279                generated_by: "none".into(),
280            },
281            voice: OsVoice::default(),
282        }
283    }
284}
285
286/// Tracks a multi-turn personality interview for a single user.
287#[derive(Debug)]
288pub struct InterviewSession {
289    pub history: Vec<roboticus_llm::format::UnifiedMessage>,
290    pub awaiting_confirmation: bool,
291    pub pending_output: Option<roboticus_core::personality::InterviewOutput>,
292    pub created_at: std::time::Instant,
293}
294
295impl Default for InterviewSession {
296    fn default() -> Self {
297        Self::new()
298    }
299}
300
301impl InterviewSession {
302    pub fn new() -> Self {
303        Self {
304            history: vec![roboticus_llm::format::UnifiedMessage {
305                role: "system".into(),
306                content: roboticus_agent::interview::build_interview_prompt(),
307                parts: None,
308            }],
309            awaiting_confirmation: false,
310            pending_output: None,
311            created_at: std::time::Instant::now(),
312        }
313    }
314}
315
316#[derive(Clone)]
317pub struct AppState {
318    pub db: Database,
319    pub config: Arc<RwLock<RoboticusConfig>>,
320    pub llm: Arc<RwLock<LlmService>>,
321    pub wallet: Arc<WalletService>,
322    pub a2a: Arc<RwLock<A2aProtocol>>,
323    pub personality: Arc<RwLock<PersonalityState>>,
324    pub hmac_secret: Arc<Vec<u8>>,
325    pub interviews: Arc<RwLock<HashMap<String, InterviewSession>>>,
326    pub plugins: Arc<PluginRegistry>,
327    pub policy_engine: Arc<PolicyEngine>,
328    pub browser: Arc<Browser>,
329    pub registry: Arc<SubagentRegistry>,
330    pub event_bus: EventBus,
331    pub channel_router: Arc<ChannelRouter>,
332    pub telegram: Option<Arc<TelegramAdapter>>,
333    pub whatsapp: Option<Arc<WhatsAppAdapter>>,
334    pub retriever: Arc<roboticus_agent::retrieval::MemoryRetriever>,
335    pub ann_index: roboticus_db::ann::AnnIndex,
336    pub tools: Arc<ToolRegistry>,
337    /// Mirror of `tools` for LLM catalog and capability-aware execution paths.
338    pub capabilities: Arc<CapabilityRegistry>,
339    pub approvals: Arc<ApprovalManager>,
340    pub discord: Option<Arc<DiscordAdapter>>,
341    pub signal: Option<Arc<SignalAdapter>>,
342    pub email: Option<Arc<EmailAdapter>>,
343    pub voice: Option<Arc<RwLock<VoicePipeline>>>,
344    pub media_service: Option<Arc<MediaService>>,
345    pub discovery: Arc<RwLock<roboticus_agent::discovery::DiscoveryRegistry>>,
346    pub devices: Arc<RwLock<roboticus_agent::device::DeviceManager>>,
347    pub mcp_clients: Arc<RwLock<roboticus_agent::mcp::McpClientManager>>,
348    pub mcp_server: Arc<RwLock<roboticus_agent::mcp::McpServerRegistry>>,
349    pub live_mcp: Arc<roboticus_agent::mcp::manager::McpConnectionManager>,
350    pub oauth: Arc<OAuthManager>,
351    pub keystore: Arc<roboticus_core::keystore::Keystore>,
352    pub obsidian: Option<Arc<RwLock<ObsidianVault>>>,
353    pub started_at: std::time::Instant,
354    pub config_path: Arc<PathBuf>,
355    pub config_apply_status: Arc<RwLock<ConfigApplyStatus>>,
356    pub pending_specialist_proposals: Arc<RwLock<HashMap<String, serde_json::Value>>>,
357    pub ws_tickets: crate::ws_ticket::TicketStore,
358    pub rate_limiter: crate::rate_limit::GlobalRateLimitLayer,
359    /// Shared semantic classifier backed by the embedding client.
360    /// Used for guard pre-computation and specialist workflow detection.
361    pub semantic_classifier: Arc<SemanticClassifier>,
362}
363
364impl AppState {
365    /// Rebuild `CapabilityRegistry` from the current `ToolRegistry` (e.g. after plugin hot-load).
366    pub async fn resync_capabilities_from_tools(&self) {
367        if let Err(e) = self
368            .capabilities
369            .sync_from_tool_registry(Arc::clone(&self.tools))
370            .await
371        {
372            tracing::warn!(error = %e, "capability resync from tools reported errors");
373        }
374    }
375
376    pub async fn reload_personality(&self) {
377        let workspace = {
378            let config = self.config.read().await;
379            config.agent.workspace.clone()
380        };
381        let new_state = PersonalityState::from_workspace(&workspace);
382        tracing::info!(
383            personality = %new_state.identity.name,
384            generated_by = %new_state.identity.generated_by,
385            "Hot-reloaded personality from workspace"
386        );
387        *self.personality.write().await = new_state;
388    }
389}
390
391// ── JSON error normalization middleware ────────────────────────
392//
393// BUG-006/014/016/017: axum returns plain-text bodies for its built-in
394// rejections (JSON parse errors, wrong Content-Type, 405 Method Not
395// Allowed). This middleware intercepts any non-JSON error response and
396// wraps it in the standard `{"error":"..."}` format.
397
398async fn json_error_layer(
399    req: axum::extract::Request,
400    next: middleware::Next,
401) -> axum::response::Response {
402    let response = next.run(req).await;
403    let status = response.status();
404
405    if !(status.is_client_error() || status.is_server_error()) {
406        return response;
407    }
408
409    let is_json = response
410        .headers()
411        .get(axum::http::header::CONTENT_TYPE)
412        .and_then(|v| v.to_str().ok())
413        .is_some_and(|ct| ct.contains("application/json"));
414    if is_json {
415        return response;
416    }
417
418    let code = response.status();
419    let (_parts, body) = response.into_parts();
420    let bytes = match axum::body::to_bytes(body, 8192).await {
421        Ok(b) => b,
422        Err(e) => {
423            tracing::warn!(error = %e, "failed to read response body for JSON wrapping");
424            axum::body::Bytes::new()
425        }
426    };
427    let original_text = String::from_utf8_lossy(&bytes);
428
429    let error_msg = if original_text.trim().is_empty() {
430        match code {
431            axum::http::StatusCode::METHOD_NOT_ALLOWED => "method not allowed".to_string(),
432            axum::http::StatusCode::NOT_FOUND => "not found".to_string(),
433            axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE => {
434                "unsupported content type: expected application/json".to_string()
435            }
436            other => other.to_string(),
437        }
438    } else {
439        sanitize_error_message(original_text.trim())
440    };
441
442    let json_body = serde_json::json!({ "error": error_msg });
443    let body_bytes = serde_json::to_vec(&json_body)
444        .unwrap_or_else(|_| br#"{"error":"internal error"}"#.to_vec());
445    let mut resp = axum::response::Response::new(axum::body::Body::from(body_bytes));
446    *resp.status_mut() = code;
447    resp.headers_mut().insert(
448        axum::http::header::CONTENT_TYPE,
449        axum::http::HeaderValue::from_static("application/json"),
450    );
451    resp
452}
453
454// ── Security headers ─────────────────────────────────────────────
455// BUG-018: Content-Security-Policy
456// BUG-019: X-Frame-Options
457
458// CSP nonce handling: the dashboard handler generates a per-request nonce,
459// injects it into inline <script> tags, and sets its own CSP header with
460// 'nonce-<value>'.  This middleware only sets the default (non-inline) CSP
461// for responses that do not already carry a Content-Security-Policy header
462// (i.e. API endpoints, redirects, etc.).
463
464async fn security_headers_layer(
465    req: axum::extract::Request,
466    next: middleware::Next,
467) -> axum::response::Response {
468    let mut response = next.run(req).await;
469    let headers = response.headers_mut();
470
471    // Only set the default CSP if the handler did not already set one
472    // (the dashboard handler sets a nonce-based CSP).
473    let csp_name = axum::http::header::HeaderName::from_static("content-security-policy");
474    if !headers.contains_key(&csp_name) {
475        headers.insert(
476            csp_name,
477            axum::http::HeaderValue::from_static(
478                "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'",
479            ),
480        );
481    }
482    headers.insert(
483        axum::http::header::X_FRAME_OPTIONS,
484        axum::http::HeaderValue::from_static("DENY"),
485    );
486    headers.insert(
487        axum::http::header::X_CONTENT_TYPE_OPTIONS,
488        axum::http::HeaderValue::from_static("nosniff"),
489    );
490    response
491}
492
493async fn dashboard_redirect() -> axum::response::Redirect {
494    axum::response::Redirect::permanent("/")
495}
496
497// ── Router ──────────────────────────────────────────────────────
498
499pub fn build_router(state: AppState) -> Router {
500    use admin::{
501        a2a_hello, breaker_open, breaker_reset, breaker_status, browser_action, browser_start,
502        browser_status, browser_stop, change_agent_model, confirm_revenue_swap_task,
503        confirm_revenue_tax_task, create_service_quote, delete_provider_key, execute_plugin_tool,
504        fail_revenue_swap_task, fail_revenue_tax_task, fail_service_request,
505        fulfill_revenue_opportunity, fulfill_service_request, generate_deep_analysis, get_agents,
506        get_available_models, get_cache_stats, get_capacity_stats, get_config,
507        get_config_apply_status, get_config_capabilities, get_config_raw, get_costs,
508        get_efficiency, get_mcp_runtime, get_memory_analytics, get_overview_timeseries,
509        get_plugins, get_recommendations, get_revenue_opportunity, get_routing_dataset,
510        get_routing_diagnostics, get_runtime_surfaces, get_service_request, get_task_events,
511        get_throttle_stats, get_transactions, get_workspace_tasks, intake_micro_bounty_opportunity,
512        intake_oracle_feed_opportunity, intake_revenue_opportunity, list_discovered_agents,
513        list_paired_devices, list_revenue_opportunities, list_revenue_swap_tasks,
514        list_revenue_tax_tasks, list_services_catalog, mcp_client_disconnect, mcp_client_discover,
515        pair_device, plan_revenue_opportunity, qualify_revenue_opportunity,
516        reconcile_revenue_swap_task, reconcile_revenue_tax_task,
517        record_revenue_opportunity_feedback, register_discovered_agent, roster, run_routing_eval,
518        score_revenue_opportunity, set_provider_key, settle_revenue_opportunity, start_agent,
519        start_revenue_swap_task, start_revenue_tax_task, stop_agent, submit_revenue_swap_task,
520        submit_revenue_tax_task, toggle_plugin, unpair_device, update_config, update_config_raw,
521        verify_discovered_agent, verify_paired_device, verify_service_payment, wallet_address,
522        wallet_balance, workspace_state,
523    };
524    use agent::{agent_message, agent_message_stream, agent_status};
525    use channels::{get_channels_status, get_dead_letters, replay_dead_letter, test_channel};
526    use cron::{
527        create_cron_job, delete_cron_job, get_cron_job, list_cron_jobs, list_cron_runs,
528        run_cron_job_now, update_cron_job,
529    };
530    use health::{get_logs, health};
531    use memory::{
532        get_episodic_memory, get_semantic_categories, get_semantic_memory, get_semantic_memory_all,
533        get_working_memory, get_working_memory_all, knowledge_ingest, memory_health, memory_search,
534    };
535    use sessions::{
536        analyze_session, analyze_turn, archive_session_handler, backfill_nicknames, create_session,
537        get_session, get_session_feedback, get_session_insights, get_turn, get_turn_context,
538        get_turn_feedback, get_turn_model_selection, get_turn_tips, get_turn_tools, list_messages,
539        list_model_selection_events, list_session_turns, list_sessions, post_message,
540        post_turn_feedback, put_turn_feedback,
541    };
542    use skills::{
543        audit_skills, catalog_activate, catalog_install, catalog_list, delete_skill, get_skill,
544        list_skills, reload_skills, toggle_skill, update_skill,
545    };
546    use subagents::{
547        create_sub_agent, delete_sub_agent, get_subagent_retirement_candidates, list_sub_agents,
548        retire_unused_subagents, toggle_sub_agent, update_sub_agent,
549    };
550    use themes::list_themes;
551
552    Router::new()
553        .route("/", get(crate::dashboard::dashboard_handler))
554        .route("/dashboard", get(dashboard_redirect))
555        .route("/dashboard/", get(dashboard_redirect))
556        .route("/api/health", get(health))
557        .route("/health", get(health))
558        .route("/api/config", get(get_config).put(update_config))
559        .route(
560            "/api/config/raw",
561            get(get_config_raw).put(update_config_raw),
562        )
563        .route("/api/config/capabilities", get(get_config_capabilities))
564        .route("/api/config/status", get(get_config_apply_status))
565        .route(
566            "/api/providers/{name}/key",
567            put(set_provider_key).delete(delete_provider_key),
568        )
569        .route("/api/logs", get(get_logs))
570        .route("/api/sessions", get(list_sessions).post(create_session))
571        .route("/api/sessions/backfill-nicknames", post(backfill_nicknames))
572        .route("/api/sessions/{id}", get(get_session))
573        .route(
574            "/api/sessions/{id}/messages",
575            get(list_messages).post(post_message),
576        )
577        .route("/api/sessions/{id}/turns", get(list_session_turns))
578        .route("/api/sessions/{id}/archive", post(archive_session_handler))
579        .route("/api/sessions/{id}/insights", get(get_session_insights))
580        .route("/api/sessions/{id}/feedback", get(get_session_feedback))
581        .route("/api/turns/{id}", get(get_turn))
582        .route("/api/turns/{id}/context", get(get_turn_context))
583        .route(
584            "/api/turns/{id}/model-selection",
585            get(get_turn_model_selection),
586        )
587        .route("/api/turns/{id}/tools", get(get_turn_tools))
588        .route("/api/turns/{id}/tips", get(get_turn_tips))
589        .route("/api/models/selections", get(list_model_selection_events))
590        .route(
591            "/api/turns/{id}/feedback",
592            get(get_turn_feedback)
593                .post(post_turn_feedback)
594                .put(put_turn_feedback),
595        )
596        .route("/api/memory/working", get(get_working_memory_all))
597        .route("/api/memory/working/{session_id}", get(get_working_memory))
598        .route("/api/memory/episodic", get(get_episodic_memory))
599        .route("/api/memory/semantic", get(get_semantic_memory_all))
600        .route(
601            "/api/memory/semantic/categories",
602            get(get_semantic_categories),
603        )
604        .route("/api/memory/semantic/{category}", get(get_semantic_memory))
605        .route("/api/memory/search", get(memory_search))
606        .route("/api/memory/health", get(memory_health))
607        .route("/api/knowledge/ingest", post(knowledge_ingest))
608        .route("/api/cron/jobs", get(list_cron_jobs).post(create_cron_job))
609        .route("/api/cron/runs", get(list_cron_runs))
610        .route(
611            "/api/cron/jobs/{id}",
612            get(get_cron_job)
613                .put(update_cron_job)
614                .delete(delete_cron_job),
615        )
616        .route(
617            "/api/cron/jobs/{id}/run",
618            axum::routing::post(run_cron_job_now),
619        )
620        .route("/api/stats/costs", get(get_costs))
621        .route("/api/stats/timeseries", get(get_overview_timeseries))
622        .route("/api/stats/efficiency", get(get_efficiency))
623        .route("/api/stats/memory-analytics", get(get_memory_analytics))
624        .route("/api/recommendations", get(get_recommendations))
625        .route("/api/stats/transactions", get(get_transactions))
626        .route("/api/services/catalog", get(list_services_catalog))
627        .route("/api/services/quote", post(create_service_quote))
628        .route("/api/services/requests/{id}", get(get_service_request))
629        .route(
630            "/api/services/requests/{id}/payment/verify",
631            post(verify_service_payment),
632        )
633        .route(
634            "/api/services/requests/{id}/fulfill",
635            post(fulfill_service_request),
636        )
637        .route(
638            "/api/services/requests/{id}/fail",
639            post(fail_service_request),
640        )
641        .route(
642            "/api/services/opportunities/intake",
643            get(list_revenue_opportunities).post(intake_revenue_opportunity),
644        )
645        .route(
646            "/api/services/opportunities/adapters/micro-bounty/intake",
647            post(intake_micro_bounty_opportunity),
648        )
649        .route(
650            "/api/services/opportunities/adapters/oracle-feed/intake",
651            post(intake_oracle_feed_opportunity),
652        )
653        .route(
654            "/api/services/opportunities/{id}",
655            get(get_revenue_opportunity),
656        )
657        .route(
658            "/api/services/opportunities/{id}/score",
659            post(score_revenue_opportunity),
660        )
661        .route(
662            "/api/services/opportunities/{id}/qualify",
663            post(qualify_revenue_opportunity),
664        )
665        .route(
666            "/api/services/opportunities/{id}/feedback",
667            post(record_revenue_opportunity_feedback),
668        )
669        .route(
670            "/api/services/opportunities/{id}/plan",
671            post(plan_revenue_opportunity),
672        )
673        .route(
674            "/api/services/opportunities/{id}/fulfill",
675            post(fulfill_revenue_opportunity),
676        )
677        .route(
678            "/api/services/opportunities/{id}/settle",
679            post(settle_revenue_opportunity),
680        )
681        .route("/api/services/swaps", get(list_revenue_swap_tasks))
682        .route("/api/services/tax-payouts", get(list_revenue_tax_tasks))
683        .route(
684            "/api/services/swaps/{id}/start",
685            post(start_revenue_swap_task),
686        )
687        .route(
688            "/api/services/swaps/{id}/submit",
689            post(submit_revenue_swap_task),
690        )
691        .route(
692            "/api/services/swaps/{id}/reconcile",
693            post(reconcile_revenue_swap_task),
694        )
695        .route(
696            "/api/services/swaps/{id}/confirm",
697            post(confirm_revenue_swap_task),
698        )
699        .route(
700            "/api/services/swaps/{id}/fail",
701            post(fail_revenue_swap_task),
702        )
703        .route(
704            "/api/services/tax-payouts/{id}/start",
705            post(start_revenue_tax_task),
706        )
707        .route(
708            "/api/services/tax-payouts/{id}/submit",
709            post(submit_revenue_tax_task),
710        )
711        .route(
712            "/api/services/tax-payouts/{id}/reconcile",
713            post(reconcile_revenue_tax_task),
714        )
715        .route(
716            "/api/services/tax-payouts/{id}/confirm",
717            post(confirm_revenue_tax_task),
718        )
719        .route(
720            "/api/services/tax-payouts/{id}/fail",
721            post(fail_revenue_tax_task),
722        )
723        .route("/api/stats/cache", get(get_cache_stats))
724        .route("/api/stats/capacity", get(get_capacity_stats))
725        .route("/api/stats/throttle", get(get_throttle_stats))
726        .route("/api/models/available", get(get_available_models))
727        .route(
728            "/api/models/routing-diagnostics",
729            get(get_routing_diagnostics),
730        )
731        .route("/api/models/routing-dataset", get(get_routing_dataset))
732        .route("/api/models/routing-eval", post(run_routing_eval))
733        .route("/api/breaker/status", get(breaker_status))
734        .route("/api/breaker/open/{provider}", post(breaker_open))
735        .route("/api/breaker/reset/{provider}", post(breaker_reset))
736        .route("/api/agent/status", get(agent_status))
737        .route("/api/agent/message", post(agent_message))
738        .route("/api/agent/message/stream", post(agent_message_stream))
739        .route("/api/wallet/balance", get(wallet_balance))
740        .route("/api/wallet/address", get(wallet_address))
741        .route("/api/skills", get(list_skills))
742        .route("/api/skills/catalog", get(catalog_list))
743        .route("/api/skills/catalog/install", post(catalog_install))
744        .route("/api/skills/catalog/activate", post(catalog_activate))
745        .route("/api/skills/audit", get(audit_skills))
746        .route(
747            "/api/skills/{id}",
748            get(get_skill).put(update_skill).delete(delete_skill),
749        )
750        .route("/api/skills/reload", post(reload_skills))
751        .route("/api/skills/{id}/toggle", put(toggle_skill))
752        .route("/api/themes", get(list_themes))
753        .route("/api/plugins/catalog/install", post(catalog_install))
754        .route("/api/plugins", get(get_plugins))
755        .route("/api/plugins/{name}/toggle", put(toggle_plugin))
756        .route(
757            "/api/plugins/{name}/execute/{tool}",
758            post(execute_plugin_tool),
759        )
760        .route("/api/browser/status", get(browser_status))
761        .route("/api/browser/start", post(browser_start))
762        .route("/api/browser/stop", post(browser_stop))
763        .route("/api/browser/action", post(browser_action))
764        .route("/api/agents", get(get_agents))
765        .route("/api/agents/{id}/start", post(start_agent))
766        .route("/api/agents/{id}/stop", post(stop_agent))
767        .route(
768            "/api/subagents",
769            get(list_sub_agents).post(create_sub_agent),
770        )
771        .route(
772            "/api/subagents/retirement-candidates",
773            get(get_subagent_retirement_candidates),
774        )
775        .route(
776            "/api/subagents/retire-unused",
777            post(retire_unused_subagents),
778        )
779        .route(
780            "/api/subagents/{name}",
781            put(update_sub_agent).delete(delete_sub_agent),
782        )
783        .route("/api/subagents/{name}/toggle", put(toggle_sub_agent))
784        .route("/api/workspace/state", get(workspace_state))
785        .route("/api/workspace/tasks", get(get_workspace_tasks))
786        .route("/api/admin/task-events", get(get_task_events))
787        .route("/api/roster", get(roster))
788        .route("/api/roster/{name}/model", put(change_agent_model))
789        .route("/api/a2a/hello", post(a2a_hello))
790        .route("/api/channels/status", get(get_channels_status))
791        .route("/api/channels/{platform}/test", post(test_channel))
792        .route("/api/channels/dead-letter", get(get_dead_letters))
793        .route(
794            "/api/channels/dead-letter/{id}/replay",
795            post(replay_dead_letter),
796        )
797        .route("/api/runtime/surfaces", get(get_runtime_surfaces))
798        .route(
799            "/api/runtime/discovery",
800            get(list_discovered_agents).post(register_discovered_agent),
801        )
802        .route(
803            "/api/runtime/discovery/{id}/verify",
804            post(verify_discovered_agent),
805        )
806        .route("/api/runtime/devices", get(list_paired_devices))
807        .route("/api/runtime/devices/pair", post(pair_device))
808        .route(
809            "/api/runtime/devices/{id}/verify",
810            post(verify_paired_device),
811        )
812        .route(
813            "/api/runtime/devices/{id}",
814            axum::routing::delete(unpair_device),
815        )
816        .route("/api/runtime/mcp", get(get_mcp_runtime))
817        .route(
818            "/api/runtime/mcp/clients/{name}/discover",
819            post(mcp_client_discover),
820        )
821        .route(
822            "/api/runtime/mcp/clients/{name}/disconnect",
823            post(mcp_client_disconnect),
824        )
825        .route("/api/mcp/servers", get(mcp::list_servers))
826        .route("/api/mcp/servers/{name}", get(mcp::get_server))
827        .route("/api/mcp/servers/{name}/test", post(mcp::test_server))
828        .route("/api/approvals", get(admin::list_approvals))
829        .route("/api/approvals/{id}/approve", post(admin::approve_request))
830        .route("/api/approvals/{id}/deny", post(admin::deny_request))
831        .route("/api/ws-ticket", post(admin::issue_ws_ticket))
832        .route("/api/interview/start", post(interview::start_interview))
833        .route("/api/interview/turn", post(interview::interview_turn))
834        .route("/api/interview/finish", post(interview::finish_interview))
835        .route("/api/audit/policy/{turn_id}", get(admin::get_policy_audit))
836        .route("/api/audit/tools/{turn_id}", get(admin::get_tool_audit))
837        .route("/api/traces/{turn_id}", get(traces::get_trace))
838        .route(
839            "/api/traces/{turn_id}/react",
840            get(traces::get_react_trace_handler),
841        )
842        .route("/api/observability/traces", get(observability::list_traces))
843        .route(
844            "/api/observability/traces/{turn_id}/waterfall",
845            get(observability::trace_waterfall),
846        )
847        .route(
848            "/api/observability/delegation/outcomes",
849            get(observability::delegation_outcomes),
850        )
851        .route(
852            "/api/observability/delegation/stats",
853            get(observability::delegation_stats),
854        )
855        .route(
856            "/favicon.ico",
857            get(|| async { axum::http::StatusCode::NO_CONTENT }),
858        )
859        // LLM analysis routes have their own concurrency limit to prevent
860        // expensive analysis requests from starving lightweight API calls.
861        .merge(
862            Router::new()
863                .route("/api/sessions/{id}/analyze", post(analyze_session))
864                .route("/api/turns/{id}/analyze", post(analyze_turn))
865                .route(
866                    "/api/recommendations/generate",
867                    post(generate_deep_analysis),
868                )
869                .layer(tower::limit::ConcurrencyLimitLayer::new(3))
870                .with_state(state.clone()),
871        )
872        .fallback(|| async { JsonError(axum::http::StatusCode::NOT_FOUND, "not found".into()) })
873        .layer(DefaultBodyLimit::max(1024 * 1024)) // 1MB
874        .layer(middleware::from_fn(json_error_layer))
875        .layer(middleware::from_fn(security_headers_layer))
876        .with_state(state)
877}
878
879/// Routes that must be accessible without API key authentication
880/// (webhooks from external services, discovery endpoints).
881pub fn build_public_router(state: AppState) -> Router {
882    use admin::agent_card;
883    use channels::{webhook_telegram, webhook_whatsapp, webhook_whatsapp_verify};
884
885    Router::new()
886        .route("/.well-known/agent.json", get(agent_card))
887        .route("/api/webhooks/telegram", post(webhook_telegram))
888        .route(
889            "/api/webhooks/whatsapp",
890            get(webhook_whatsapp_verify).post(webhook_whatsapp),
891        )
892        .layer(DefaultBodyLimit::max(1024 * 1024)) // 1MB — match auth router
893        .with_state(state)
894}
895
896// ── MCP Gateway (P.1) ─────────────────────────────────────────
897
898/// Builds an axum `Router` that serves the MCP protocol endpoint.
899///
900/// The returned router should be merged at the top level — it handles
901/// its own transport (POST for JSON-RPC, GET for SSE, DELETE for sessions)
902/// under the `/mcp` prefix via rmcp's `StreamableHttpService`.
903///
904/// Auth: MCP clients authenticate via `Authorization: Bearer <api_key>`.
905/// The same API key used for the REST API is accepted here.
906pub fn build_mcp_router(state: &AppState, api_key: Option<String>) -> Router {
907    use crate::auth::ApiKeyLayer;
908    use rmcp::transport::streamable_http_server::{
909        StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
910    };
911    use roboticus_agent::mcp_handler::{McpToolContext, RoboticusMcpHandler};
912    use std::time::Duration;
913
914    let mcp_ctx = {
915        let (workspace_root, agent_name, tool_allowed_paths, sandbox) = state
916            .config
917            .try_read()
918            .map(|c| {
919                (
920                    c.agent.workspace.clone(),
921                    c.agent.name.clone(),
922                    c.security.filesystem.tool_allowed_paths.clone(),
923                    roboticus_agent::tools::ToolSandboxSnapshot::from_config(
924                        &c.security.filesystem,
925                        &c.skills,
926                    ),
927                )
928            })
929            .unwrap_or_else(|_| {
930                (
931                    std::path::PathBuf::from("."),
932                    "roboticus".to_string(),
933                    Vec::new(),
934                    roboticus_agent::tools::ToolSandboxSnapshot::default(),
935                )
936            });
937        McpToolContext {
938            agent_id: "roboticus-mcp-gateway".to_string(),
939            agent_name,
940            workspace_root,
941            tool_allowed_paths,
942            sandbox,
943            db: Some(state.db.clone()),
944        }
945    };
946
947    let handler = RoboticusMcpHandler::new(state.tools.clone(), mcp_ctx);
948
949    let config = StreamableHttpServerConfig {
950        sse_keep_alive: Some(Duration::from_secs(15)),
951        stateful_mode: true,
952        ..Default::default()
953    };
954
955    let service = StreamableHttpService::new(
956        move || Ok(handler.clone()),
957        Arc::new(LocalSessionManager::default()),
958        config,
959    );
960
961    Router::new()
962        .nest_service("/mcp", service)
963        .layer(ApiKeyLayer::new(api_key))
964}
965
966// ── Re-exports for api.rs and lib.rs ────────────────────────────
967
968pub use agent::{discord_poll_loop, email_poll_loop, signal_poll_loop, telegram_poll_loop};
969pub use health::LogEntry;
970
971// ── Tests ─────────────────────────────────────────────────────
972
973#[cfg(test)]
974#[path = "tests.rs"]
975mod tests;