1mod admin;
2mod agent;
3mod channels;
4mod cron;
5mod health;
6mod interview;
7mod memory;
8mod sessions;
9mod skill_authoring;
10mod skills;
11pub(crate) mod subagent_integrity;
12pub(crate) use self::agent::execute_scheduled_agent_task;
13mod subagents;
14
15use std::collections::HashMap;
16use std::path::PathBuf;
17use std::sync::Arc;
18
19use axum::extract::DefaultBodyLimit;
20use axum::{
21 Router, middleware,
22 routing::{get, post, put},
23};
24use tokio::sync::RwLock;
25
26use crate::config_runtime::ConfigApplyStatus;
27use roboticus_agent::policy::PolicyEngine;
28use roboticus_agent::subagents::SubagentRegistry;
29use roboticus_browser::Browser;
30use roboticus_channels::a2a::A2aProtocol;
31use roboticus_channels::router::ChannelRouter;
32use roboticus_channels::telegram::TelegramAdapter;
33use roboticus_channels::whatsapp::WhatsAppAdapter;
34use roboticus_core::RoboticusConfig;
35use roboticus_core::personality::{self, OsIdentity, OsVoice};
36use roboticus_db::Database;
37use roboticus_llm::LlmService;
38use roboticus_llm::OAuthManager;
39use roboticus_plugin_sdk::registry::PluginRegistry;
40use roboticus_wallet::WalletService;
41
42use roboticus_agent::approvals::ApprovalManager;
43use roboticus_agent::capability::CapabilityRegistry;
44use roboticus_agent::obsidian::ObsidianVault;
45use roboticus_agent::tools::ToolRegistry;
46use roboticus_channels::discord::DiscordAdapter;
47use roboticus_channels::email::EmailAdapter;
48use roboticus_channels::media::MediaService;
49use roboticus_channels::signal::SignalAdapter;
50use roboticus_channels::voice::VoicePipeline;
51
52use crate::ws::EventBus;
53
54#[derive(Debug)]
59pub(crate) struct JsonError(pub axum::http::StatusCode, pub String);
60
61impl axum::response::IntoResponse for JsonError {
62 fn into_response(self) -> axum::response::Response {
63 let body = serde_json::json!({ "error": self.1 });
64 (self.0, axum::Json(body)).into_response()
65 }
66}
67
68impl From<(axum::http::StatusCode, String)> for JsonError {
69 fn from((status, msg): (axum::http::StatusCode, String)) -> Self {
70 Self(status, msg)
71 }
72}
73
74pub(crate) fn bad_request(msg: impl std::fmt::Display) -> JsonError {
76 JsonError(axum::http::StatusCode::BAD_REQUEST, msg.to_string())
77}
78
79pub(crate) fn not_found(msg: impl std::fmt::Display) -> JsonError {
81 JsonError(axum::http::StatusCode::NOT_FOUND, msg.to_string())
82}
83
84pub(crate) fn sanitize_error_message(msg: &str) -> String {
95 let sanitized = msg.lines().next().unwrap_or(msg);
96
97 let sanitized = sanitized
98 .trim_start_matches("Database(\"")
99 .trim_end_matches("\")")
100 .trim_start_matches("Wallet(\"")
101 .trim_end_matches("\")");
102
103 let sensitive_prefixes = [
106 "at /", "called `Result::unwrap()` on an `Err` value:",
108 "SQLITE_", "Connection refused", "constraint failed", "no such table", "no such column", "UNIQUE constraint", "FOREIGN KEY constraint", "NOT NULL constraint", ];
117 let sanitized = {
118 let mut s = sanitized.to_string();
119 for prefix in &sensitive_prefixes {
120 if let Some(pos) = s.find(prefix) {
121 s.truncate(pos);
122 s.push_str("[details redacted]");
123 break;
124 }
125 }
126 s
127 };
128
129 if sanitized.len() > 200 {
130 let boundary = sanitized
131 .char_indices()
132 .map(|(i, _)| i)
133 .take_while(|&i| i <= 200)
134 .last()
135 .unwrap_or(0);
136 format!("{}...", &sanitized[..boundary])
137 } else {
138 sanitized
139 }
140}
141
142pub(crate) fn internal_err(e: &impl std::fmt::Display) -> JsonError {
144 tracing::error!(error = %e, "request failed");
145 JsonError(
146 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
147 sanitize_error_message(&e.to_string()),
148 )
149}
150
151const MAX_SHORT_FIELD: usize = 256;
155const MAX_LONG_FIELD: usize = 4096;
157
158pub(crate) fn validate_field(
160 field_name: &str,
161 value: &str,
162 max_len: usize,
163) -> Result<(), JsonError> {
164 if value.trim().is_empty() {
165 return Err(bad_request(format!("{field_name} must not be empty")));
166 }
167 if value.contains('\0') {
168 return Err(bad_request(format!(
169 "{field_name} must not contain null bytes"
170 )));
171 }
172 if value.len() > max_len {
173 return Err(bad_request(format!(
174 "{field_name} exceeds max length ({max_len})"
175 )));
176 }
177 Ok(())
178}
179
180pub(crate) fn validate_short(field_name: &str, value: &str) -> Result<(), JsonError> {
182 validate_field(field_name, value, MAX_SHORT_FIELD)
183}
184
185pub(crate) fn validate_long(field_name: &str, value: &str) -> Result<(), JsonError> {
187 validate_field(field_name, value, MAX_LONG_FIELD)
188}
189
190pub(crate) fn sanitize_html(input: &str) -> String {
192 input
193 .replace('&', "&")
194 .replace('<', "<")
195 .replace('>', ">")
196 .replace('"', """)
197 .replace('\'', "'")
198}
199
200const DEFAULT_PAGE_SIZE: i64 = 200;
204const MAX_PAGE_SIZE: i64 = 500;
206
207#[derive(Debug, serde::Deserialize)]
209pub(crate) struct PaginationQuery {
210 pub limit: Option<i64>,
211 pub offset: Option<i64>,
212}
213
214impl PaginationQuery {
215 pub fn resolve(&self) -> (i64, i64) {
217 let limit = self
218 .limit
219 .unwrap_or(DEFAULT_PAGE_SIZE)
220 .clamp(1, MAX_PAGE_SIZE);
221 let offset = self.offset.unwrap_or(0).max(0);
222 (limit, offset)
223 }
224}
225
226#[derive(Debug, Clone)]
230pub struct PersonalityState {
231 pub os_text: String,
232 pub firmware_text: String,
233 pub identity: OsIdentity,
234 pub voice: OsVoice,
235}
236
237impl PersonalityState {
238 pub fn from_workspace(workspace: &std::path::Path) -> Self {
239 let os = personality::load_os(workspace);
240 let fw = personality::load_firmware(workspace);
241 let operator = personality::load_operator(workspace);
242 let directives = personality::load_directives(workspace);
243
244 let os_text =
245 personality::compose_identity_text(os.as_ref(), operator.as_ref(), directives.as_ref());
246 let firmware_text = personality::compose_firmware_text(fw.as_ref());
247
248 let (identity, voice) = match os {
249 Some(os) => (os.identity, os.voice),
250 None => (
251 OsIdentity {
252 name: String::new(),
253 version: "1.0".into(),
254 generated_by: "none".into(),
255 },
256 OsVoice::default(),
257 ),
258 };
259
260 Self {
261 os_text,
262 firmware_text,
263 identity,
264 voice,
265 }
266 }
267
268 pub fn empty() -> Self {
269 Self {
270 os_text: String::new(),
271 firmware_text: String::new(),
272 identity: OsIdentity {
273 name: String::new(),
274 version: "1.0".into(),
275 generated_by: "none".into(),
276 },
277 voice: OsVoice::default(),
278 }
279 }
280}
281
282#[derive(Debug)]
284pub struct InterviewSession {
285 pub history: Vec<roboticus_llm::format::UnifiedMessage>,
286 pub awaiting_confirmation: bool,
287 pub pending_output: Option<roboticus_core::personality::InterviewOutput>,
288 pub created_at: std::time::Instant,
289}
290
291impl Default for InterviewSession {
292 fn default() -> Self {
293 Self::new()
294 }
295}
296
297impl InterviewSession {
298 pub fn new() -> Self {
299 Self {
300 history: vec![roboticus_llm::format::UnifiedMessage {
301 role: "system".into(),
302 content: roboticus_agent::interview::build_interview_prompt(),
303 parts: None,
304 }],
305 awaiting_confirmation: false,
306 pending_output: None,
307 created_at: std::time::Instant::now(),
308 }
309 }
310}
311
312#[derive(Clone)]
313pub struct AppState {
314 pub db: Database,
315 pub config: Arc<RwLock<RoboticusConfig>>,
316 pub llm: Arc<RwLock<LlmService>>,
317 pub dedup: Arc<std::sync::Mutex<roboticus_llm::DedupTracker>>,
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 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 oauth: Arc<OAuthManager>,
347 pub keystore: Arc<roboticus_core::keystore::Keystore>,
348 pub obsidian: Option<Arc<RwLock<ObsidianVault>>>,
349 pub started_at: std::time::Instant,
350 pub config_path: Arc<PathBuf>,
351 pub config_apply_status: Arc<RwLock<ConfigApplyStatus>>,
352 pub pending_specialist_proposals: Arc<RwLock<HashMap<String, serde_json::Value>>>,
353 pub ws_tickets: crate::ws_ticket::TicketStore,
354 pub rate_limiter: crate::rate_limit::GlobalRateLimitLayer,
355 pub shutdown_token: tokio_util::sync::CancellationToken,
356}
357
358impl AppState {
359 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
385async 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
448async 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 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
491pub 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_costs, get_delivery_stats,
502 get_efficiency, get_heartbeat_results, get_mcp_runtime, get_memory_analytics,
503 get_overview_timeseries, get_pipeline_trace_by_id, get_pipeline_traces, get_plugins,
504 get_recommendations, get_revenue_opportunity, get_routing_dataset, get_routing_diagnostics,
505 get_runtime_surfaces, get_service_request, get_session_costs, get_throttle_stats,
506 get_transactions, intake_micro_bounty_opportunity, intake_oracle_feed_opportunity,
507 intake_revenue_opportunity, list_discovered_agents, list_paired_devices,
508 list_revenue_opportunities, list_revenue_swap_tasks, list_revenue_tax_tasks,
509 list_services_catalog, mcp_client_disconnect, mcp_client_discover, pair_device,
510 plan_revenue_opportunity, qualify_revenue_opportunity, reconcile_revenue_swap_task,
511 reconcile_revenue_tax_task, record_revenue_opportunity_feedback, register_discovered_agent,
512 roster, run_routing_eval, score_revenue_opportunity, set_provider_key,
513 settle_revenue_opportunity, start_agent, start_revenue_swap_task, start_revenue_tax_task,
514 stop_agent, submit_revenue_swap_task, submit_revenue_tax_task, toggle_plugin,
515 unpair_device, update_config, verify_discovered_agent, verify_paired_device,
516 verify_service_payment, wallet_address, 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_maintenance_log, get_semantic_categories, get_semantic_memory,
527 get_semantic_memory_all, get_working_memory, get_working_memory_all, knowledge_ingest,
528 memory_search,
529 };
530 use sessions::{
531 analyze_session, analyze_turn, backfill_nicknames, create_session, get_session,
532 get_session_feedback, get_session_insights, get_turn, get_turn_context, get_turn_feedback,
533 get_turn_model_selection, get_turn_tips, get_turn_tools, list_messages,
534 list_model_selection_events, list_session_turns, list_sessions, post_message,
535 post_turn_feedback, put_turn_feedback,
536 };
537 use skill_authoring::{scaffold_skill, test_skill, validate_skill};
538 use skills::{
539 audit_skills, catalog_activate, catalog_install, catalog_list, delete_skill, get_skill,
540 list_skills, reload_skills, toggle_skill,
541 };
542 use subagents::{
543 create_sub_agent, delete_sub_agent, get_subagent_retirement_candidates, list_sub_agents,
544 retire_unused_subagents, toggle_sub_agent, update_sub_agent,
545 };
546
547 Router::new()
548 .route("/openapi.json", get(crate::openapi::get_openapi_spec))
549 .route("/docs", get(crate::openapi::get_docs_redirect))
550 .route("/", get(crate::dashboard::dashboard_handler))
551 .route("/dashboard", get(dashboard_redirect))
552 .route("/dashboard/", get(dashboard_redirect))
553 .route("/api/health", get(health))
554 .route("/health", get(health))
555 .route("/api/config", get(get_config).put(update_config))
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}/insights", get(get_session_insights))
572 .route("/api/sessions/{id}/feedback", get(get_session_feedback))
573 .route("/api/turns/{id}", get(get_turn))
574 .route("/api/turns/{id}/context", get(get_turn_context))
575 .route(
576 "/api/turns/{id}/model-selection",
577 get(get_turn_model_selection),
578 )
579 .route("/api/turns/{id}/tools", get(get_turn_tools))
580 .route("/api/turns/{id}/tips", get(get_turn_tips))
581 .route("/api/models/selections", get(list_model_selection_events))
582 .route(
583 "/api/turns/{id}/feedback",
584 get(get_turn_feedback)
585 .post(post_turn_feedback)
586 .put(put_turn_feedback),
587 )
588 .route("/api/memory/working", get(get_working_memory_all))
589 .route("/api/memory/working/{session_id}", get(get_working_memory))
590 .route("/api/memory/episodic", get(get_episodic_memory))
591 .route("/api/memory/semantic", get(get_semantic_memory_all))
592 .route(
593 "/api/memory/semantic/categories",
594 get(get_semantic_categories),
595 )
596 .route("/api/memory/semantic/{category}", get(get_semantic_memory))
597 .route("/api/memory/search", get(memory_search))
598 .route("/api/memory/maintenance-log", get(get_maintenance_log))
599 .route("/api/knowledge/ingest", post(knowledge_ingest))
600 .route("/api/cron/jobs", get(list_cron_jobs).post(create_cron_job))
601 .route("/api/cron/runs", get(list_cron_runs))
602 .route(
603 "/api/cron/jobs/{id}",
604 get(get_cron_job)
605 .put(update_cron_job)
606 .delete(delete_cron_job),
607 )
608 .route(
609 "/api/cron/jobs/{id}/run",
610 axum::routing::post(run_cron_job_now),
611 )
612 .route("/api/stats/costs", get(get_costs))
613 .route("/api/stats/costs/sessions", get(get_session_costs))
614 .route("/api/stats/timeseries", get(get_overview_timeseries))
615 .route("/api/stats/efficiency", get(get_efficiency))
616 .route("/api/recommendations", get(get_recommendations))
617 .route("/api/stats/delivery", get(get_delivery_stats))
618 .route("/api/stats/transactions", get(get_transactions))
619 .route("/api/stats/pipeline-traces", get(get_pipeline_traces))
620 .route(
621 "/api/stats/pipeline-traces/{trace_id}",
622 get(get_pipeline_trace_by_id),
623 )
624 .route("/api/stats/memory-analytics", get(get_memory_analytics))
625 .route("/api/stats/heartbeat", get(get_heartbeat_results))
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("/api/skills/{id}", get(get_skill).delete(delete_skill))
747 .route("/api/skills/reload", post(reload_skills))
748 .route("/api/skills/{id}/toggle", put(toggle_skill))
749 .route("/api/skills/validate", post(validate_skill))
750 .route("/api/skills/test", post(test_skill))
751 .route("/api/skills/scaffold", post(scaffold_skill))
752 .route("/api/plugins/catalog/install", post(catalog_install))
753 .route("/api/plugins", get(get_plugins))
754 .route("/api/plugins/{name}/toggle", put(toggle_plugin))
755 .route(
756 "/api/plugins/{name}/execute/{tool}",
757 post(execute_plugin_tool),
758 )
759 .route("/api/browser/status", get(browser_status))
760 .route("/api/browser/start", post(browser_start))
761 .route("/api/browser/stop", post(browser_stop))
762 .route("/api/browser/action", post(browser_action))
763 .route("/api/agents", get(get_agents))
764 .route("/api/agents/{id}/start", post(start_agent))
765 .route("/api/agents/{id}/stop", post(stop_agent))
766 .route(
767 "/api/subagents",
768 get(list_sub_agents).post(create_sub_agent),
769 )
770 .route(
771 "/api/subagents/retirement-candidates",
772 get(get_subagent_retirement_candidates),
773 )
774 .route(
775 "/api/subagents/retire-unused",
776 post(retire_unused_subagents),
777 )
778 .route(
779 "/api/subagents/{name}",
780 put(update_sub_agent).delete(delete_sub_agent),
781 )
782 .route("/api/subagents/{name}/toggle", put(toggle_sub_agent))
783 .route("/api/workspace/state", get(workspace_state))
784 .route("/api/roster", get(roster))
785 .route("/api/roster/{name}/model", put(change_agent_model))
786 .route("/api/a2a/hello", post(a2a_hello))
787 .route("/api/channels/status", get(get_channels_status))
788 .route("/api/channels/{platform}/test", post(test_channel))
789 .route("/api/channels/dead-letter", get(get_dead_letters))
790 .route(
791 "/api/channels/dead-letter/{id}/replay",
792 post(replay_dead_letter),
793 )
794 .route("/api/runtime/surfaces", get(get_runtime_surfaces))
795 .route(
796 "/api/runtime/discovery",
797 get(list_discovered_agents).post(register_discovered_agent),
798 )
799 .route(
800 "/api/runtime/discovery/{id}/verify",
801 post(verify_discovered_agent),
802 )
803 .route("/api/runtime/devices", get(list_paired_devices))
804 .route("/api/runtime/devices/pair", post(pair_device))
805 .route(
806 "/api/runtime/devices/{id}/verify",
807 post(verify_paired_device),
808 )
809 .route(
810 "/api/runtime/devices/{id}",
811 axum::routing::delete(unpair_device),
812 )
813 .route("/api/runtime/mcp", get(get_mcp_runtime))
814 .route(
815 "/api/runtime/mcp/clients/{name}/discover",
816 post(mcp_client_discover),
817 )
818 .route(
819 "/api/runtime/mcp/clients/{name}/disconnect",
820 post(mcp_client_disconnect),
821 )
822 .route("/api/approvals", get(admin::list_approvals))
823 .route("/api/approvals/{id}/approve", post(admin::approve_request))
824 .route("/api/approvals/{id}/deny", post(admin::deny_request))
825 .route("/api/ws-ticket", post(admin::issue_ws_ticket))
826 .route("/api/interview/start", post(interview::start_interview))
827 .route("/api/interview/turn", post(interview::interview_turn))
828 .route("/api/interview/finish", post(interview::finish_interview))
829 .route("/api/audit/policy/{turn_id}", get(admin::get_policy_audit))
830 .route("/api/audit/tools/{turn_id}", get(admin::get_tool_audit))
831 .route(
832 "/favicon.ico",
833 get(|| async { axum::http::StatusCode::NO_CONTENT }),
834 )
835 .merge(
838 Router::new()
839 .route("/api/sessions/{id}/analyze", post(analyze_session))
840 .route("/api/turns/{id}/analyze", post(analyze_turn))
841 .route(
842 "/api/recommendations/generate",
843 post(generate_deep_analysis),
844 )
845 .layer(tower::limit::ConcurrencyLimitLayer::new(3))
846 .with_state(state.clone()),
847 )
848 .fallback(|| async { JsonError(axum::http::StatusCode::NOT_FOUND, "not found".into()) })
849 .layer(DefaultBodyLimit::max(1024 * 1024)) .layer(middleware::from_fn(json_error_layer))
851 .layer(middleware::from_fn(security_headers_layer))
852 .with_state(state)
853}
854
855pub fn build_public_router(state: AppState) -> Router {
858 use admin::agent_card;
859 use channels::{webhook_telegram, webhook_whatsapp, webhook_whatsapp_verify};
860
861 Router::new()
862 .route("/.well-known/agent.json", get(agent_card))
863 .route("/api/webhooks/telegram", post(webhook_telegram))
864 .route(
865 "/api/webhooks/whatsapp",
866 get(webhook_whatsapp_verify).post(webhook_whatsapp),
867 )
868 .layer(DefaultBodyLimit::max(1024 * 1024)) .with_state(state)
870}
871
872pub fn build_mcp_router(state: &AppState, api_key: Option<String>) -> Router {
883 use crate::auth::ApiKeyLayer;
884 use rmcp::transport::streamable_http_server::{
885 StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
886 };
887 use roboticus_agent::mcp_handler::{McpToolContext, RoboticusMcpHandler};
888 use std::time::Duration;
889
890 let mcp_ctx = {
891 let (workspace_root, agent_name, tool_allowed_paths, sandbox) = state
892 .config
893 .try_read()
894 .map(|c| {
895 (
896 c.agent.workspace.clone(),
897 c.agent.name.clone(),
898 c.security.filesystem.tool_allowed_paths.clone(),
899 roboticus_agent::tools::ToolSandboxSnapshot::from_config(
900 &c.security.filesystem,
901 &c.skills,
902 ),
903 )
904 })
905 .unwrap_or_else(|_| {
906 (
907 std::path::PathBuf::from("."),
908 "roboticus".to_string(),
909 Vec::new(),
910 roboticus_agent::tools::ToolSandboxSnapshot::default(),
911 )
912 });
913 McpToolContext {
914 agent_id: "roboticus-mcp-gateway".to_string(),
915 agent_name,
916 workspace_root,
917 tool_allowed_paths,
918 sandbox,
919 db: Some(state.db.clone()),
920 }
921 };
922
923 let handler = RoboticusMcpHandler::new(state.tools.clone(), mcp_ctx);
924
925 let config = StreamableHttpServerConfig {
926 sse_keep_alive: Some(Duration::from_secs(15)),
927 stateful_mode: true,
928 ..Default::default()
929 };
930
931 let service = StreamableHttpService::new(
932 move || Ok(handler.clone()),
933 Arc::new(LocalSessionManager::default()),
934 config,
935 );
936
937 Router::new()
938 .nest_service("/mcp", service)
939 .layer(ApiKeyLayer::new(api_key))
940}
941
942pub use agent::{discord_poll_loop, email_poll_loop, signal_poll_loop, telegram_poll_loop};
945pub use health::LogEntry;
946
947#[cfg(test)]
950#[path = "tests.rs"]
951mod tests;