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