//! Axum HTTP server for the web gateway. //! //! Handles all API routes: chat, memory, jobs, health, and static file serving. use std::convert::Infallible; use std::net::SocketAddr; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use axum::{ Json, Router, extract::{DefaultBodyLimit, Path, Query, State, WebSocketUpgrade}, http::{StatusCode, header}, middleware, response::{ IntoResponse, sse::{Event, KeepAlive, Sse}, }, routing::{get, post}, }; use serde::Deserialize; use tokio::sync::{mpsc, oneshot}; use tokio_stream::StreamExt; use tower_http::cors::{AllowHeaders, CorsLayer}; use tower_http::set_header::SetResponseHeaderLayer; use uuid::Uuid; use crate::agent::SessionManager; use crate::bootstrap::ironclaw_base_dir; use crate::channels::IncomingMessage; use crate::channels::relay::DEFAULT_RELAY_NAME; use crate::channels::web::auth::{AuthState, auth_middleware}; use crate::channels::web::handlers::jobs::{ job_files_list_handler, job_files_read_handler, jobs_cancel_handler, jobs_detail_handler, jobs_events_handler, jobs_list_handler, jobs_prompt_handler, jobs_restart_handler, jobs_summary_handler, }; use crate::channels::web::handlers::skills::{ skills_install_handler, skills_list_handler, skills_remove_handler, skills_search_handler, }; use crate::channels::web::log_layer::LogBroadcaster; use crate::channels::web::sse::SseManager; use crate::channels::web::types::*; use crate::channels::web::util::{build_turns_from_db_messages, truncate_preview}; use crate::db::Database; use crate::extensions::ExtensionManager; use crate::orchestrator::job_manager::ContainerJobManager; use crate::tools::ToolRegistry; use crate::workspace::Workspace; /// Shared prompt queue: maps job IDs to pending follow-up prompts for Claude Code bridges. pub type PromptQueue = Arc< tokio::sync::Mutex< std::collections::HashMap< uuid::Uuid, std::collections::VecDeque, >, >, >; /// Slot for the routine engine, filled at runtime after the agent starts. pub type RoutineEngineSlot = Arc>>>; /// Simple sliding-window rate limiter. /// /// Tracks the number of requests in the current window. Resets when the window expires. /// Not per-IP (since this is a single-user gateway with auth), but prevents flooding. pub struct RateLimiter { /// Requests remaining in the current window. remaining: AtomicU64, /// Epoch second when the current window started. window_start: AtomicU64, /// Maximum requests per window. max_requests: u64, /// Window duration in seconds. window_secs: u64, } impl RateLimiter { pub fn new(max_requests: u64, window_secs: u64) -> Self { Self { remaining: AtomicU64::new(max_requests), window_start: AtomicU64::new( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(), ), max_requests, window_secs, } } /// Try to consume one request. Returns `true` if allowed, `false` if rate limited. pub fn check(&self) -> bool { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); let window = self.window_start.load(Ordering::Relaxed); if now.saturating_sub(window) >= self.window_secs { // Window expired, reset self.window_start.store(now, Ordering::Relaxed); self.remaining .store(self.max_requests - 1, Ordering::Relaxed); return true; } // Try to decrement remaining loop { let current = self.remaining.load(Ordering::Relaxed); if current == 0 { return false; } if self .remaining .compare_exchange_weak(current, current - 1, Ordering::Relaxed, Ordering::Relaxed) .is_ok() { return true; } } } } /// Shared state for all gateway handlers. pub struct GatewayState { /// Channel to send messages to the agent loop. pub msg_tx: tokio::sync::RwLock>>, /// SSE broadcast manager. pub sse: SseManager, /// Workspace for memory API. pub workspace: Option>, /// Session manager for thread info. pub session_manager: Option>, /// Log broadcaster for the logs SSE endpoint. pub log_broadcaster: Option>, /// Handle for changing the tracing log level at runtime. pub log_level_handle: Option>, /// Extension manager for extension management API. pub extension_manager: Option>, /// Tool registry for listing registered tools. pub tool_registry: Option>, /// Database store for sandbox job persistence. pub store: Option>, /// Container job manager for sandbox operations. pub job_manager: Option>, /// Prompt queue for Claude Code follow-up prompts. pub prompt_queue: Option, /// User ID for this gateway. pub user_id: String, /// Shutdown signal sender. pub shutdown_tx: tokio::sync::RwLock>>, /// WebSocket connection tracker. pub ws_tracker: Option>, /// LLM provider for OpenAI-compatible API proxy. pub llm_provider: Option>, /// Skill registry for skill management API. pub skill_registry: Option>>, /// Skill catalog for searching the ClawHub registry. pub skill_catalog: Option>, /// Scheduler for sending follow-up messages to running agent jobs. pub scheduler: Option, /// Rate limiter for chat endpoints (30 messages per 60 seconds). pub chat_rate_limiter: RateLimiter, /// Rate limiter for OAuth callback endpoints (10 requests per 60 seconds). pub oauth_rate_limiter: RateLimiter, /// Registry catalog entries for the available extensions API. /// Populated at startup from `registry/` manifests, independent of extension manager. pub registry_entries: Vec, /// Cost guard for token/cost tracking. pub cost_guard: Option>, /// Routine engine slot for manual routine triggering (filled at runtime). pub routine_engine: RoutineEngineSlot, /// Server startup time for uptime calculation. pub startup_time: std::time::Instant, } /// Start the gateway HTTP server. /// /// Returns the actual bound `SocketAddr` (useful when binding to port 0). pub async fn start_server( addr: SocketAddr, state: Arc, auth_token: String, ) -> Result { let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| { crate::error::ChannelError::StartupFailed { name: "gateway".to_string(), reason: format!("Failed to bind to {}: {}", addr, e), } })?; let bound_addr = listener .local_addr() .map_err(|e| crate::error::ChannelError::StartupFailed { name: "gateway".to_string(), reason: format!("Failed to get local addr: {}", e), })?; // Public routes (no auth) let public = Router::new() .route("/api/health", get(health_handler)) .route("/oauth/callback", get(oauth_callback_handler)) .route( "/oauth/slack/callback", get(slack_relay_oauth_callback_handler), ); // Protected routes (require auth) let auth_state = AuthState { token: auth_token }; let protected = Router::new() // Chat .route("/api/chat/send", post(chat_send_handler)) .route("/api/chat/approval", post(chat_approval_handler)) .route("/api/chat/auth-token", post(chat_auth_token_handler)) .route("/api/chat/auth-cancel", post(chat_auth_cancel_handler)) .route("/api/chat/events", get(chat_events_handler)) .route("/api/chat/ws", get(chat_ws_handler)) .route("/api/chat/history", get(chat_history_handler)) .route("/api/chat/threads", get(chat_threads_handler)) .route("/api/chat/thread/new", post(chat_new_thread_handler)) // Memory .route("/api/memory/tree", get(memory_tree_handler)) .route("/api/memory/list", get(memory_list_handler)) .route("/api/memory/read", get(memory_read_handler)) .route("/api/memory/write", post(memory_write_handler)) .route("/api/memory/search", post(memory_search_handler)) // Jobs .route("/api/jobs", get(jobs_list_handler)) .route("/api/jobs/summary", get(jobs_summary_handler)) .route("/api/jobs/{id}", get(jobs_detail_handler)) .route("/api/jobs/{id}/cancel", post(jobs_cancel_handler)) .route("/api/jobs/{id}/restart", post(jobs_restart_handler)) .route("/api/jobs/{id}/prompt", post(jobs_prompt_handler)) .route("/api/jobs/{id}/events", get(jobs_events_handler)) .route("/api/jobs/{id}/files/list", get(job_files_list_handler)) .route("/api/jobs/{id}/files/read", get(job_files_read_handler)) // Logs .route("/api/logs/events", get(logs_events_handler)) .route("/api/logs/level", get(logs_level_get_handler)) .route( "/api/logs/level", axum::routing::put(logs_level_set_handler), ) // Extensions .route("/api/extensions", get(extensions_list_handler)) .route("/api/extensions/tools", get(extensions_tools_handler)) .route("/api/extensions/registry", get(extensions_registry_handler)) .route("/api/extensions/install", post(extensions_install_handler)) .route( "/api/extensions/{name}/activate", post(extensions_activate_handler), ) .route( "/api/extensions/{name}/remove", post(extensions_remove_handler), ) .route( "/api/extensions/{name}/setup", get(extensions_setup_handler).post(extensions_setup_submit_handler), ) // Pairing .route("/api/pairing/{channel}", get(pairing_list_handler)) .route( "/api/pairing/{channel}/approve", post(pairing_approve_handler), ) // Routines .route("/api/routines", get(routines_list_handler)) .route("/api/routines/summary", get(routines_summary_handler)) .route("/api/routines/{id}", get(routines_detail_handler)) .route("/api/routines/{id}/trigger", post(routines_trigger_handler)) .route("/api/routines/{id}/toggle", post(routines_toggle_handler)) .route( "/api/routines/{id}", axum::routing::delete(routines_delete_handler), ) .route("/api/routines/{id}/runs", get(routines_runs_handler)) // Skills .route("/api/skills", get(skills_list_handler)) .route("/api/skills/search", post(skills_search_handler)) .route("/api/skills/install", post(skills_install_handler)) .route( "/api/skills/{name}", axum::routing::delete(skills_remove_handler), ) // Settings .route("/api/settings", get(settings_list_handler)) .route("/api/settings/export", get(settings_export_handler)) .route("/api/settings/import", post(settings_import_handler)) .route("/api/settings/{key}", get(settings_get_handler)) .route( "/api/settings/{key}", axum::routing::put(settings_set_handler), ) .route( "/api/settings/{key}", axum::routing::delete(settings_delete_handler), ) // Gateway control plane .route("/api/gateway/status", get(gateway_status_handler)) // OpenAI-compatible API .route( "/v1/chat/completions", post(super::openai_compat::chat_completions_handler), ) .route("/v1/models", get(super::openai_compat::models_handler)) .route_layer(middleware::from_fn_with_state( auth_state.clone(), auth_middleware, )); // Static file routes (no auth, served from embedded strings) let statics = Router::new() .route("/", get(index_handler)) .route("/style.css", get(css_handler)) .route("/app.js", get(js_handler)) .route("/favicon.ico", get(favicon_handler)) .route("/i18n/index.js", get(i18n_index_handler)) .route("/i18n/en.js", get(i18n_en_handler)) .route("/i18n/zh-CN.js", get(i18n_zh_handler)) .route("/i18n-app.js", get(i18n_app_handler)); // Project file serving (behind auth to prevent unauthorized file access). let projects = Router::new() .route("/projects/{project_id}", get(project_redirect_handler)) .route("/projects/{project_id}/", get(project_index_handler)) .route("/projects/{project_id}/{*path}", get(project_file_handler)) .route_layer(middleware::from_fn_with_state( auth_state.clone(), auth_middleware, )); // CORS: restrict to same-origin by default. Only localhost/127.0.0.1 // origins are allowed, since the gateway is a local-first service. let cors = CorsLayer::new() .allow_origin([ format!("http://{}:{}", addr.ip(), addr.port()) .parse() .expect("valid origin"), format!("http://localhost:{}", addr.port()) .parse() .expect("valid origin"), ]) .allow_methods([ axum::http::Method::GET, axum::http::Method::POST, axum::http::Method::PUT, axum::http::Method::DELETE, ]) .allow_headers(AllowHeaders::list([ header::CONTENT_TYPE, header::AUTHORIZATION, ])) .allow_credentials(true); let app = Router::new() .merge(public) .merge(statics) .merge(projects) .merge(protected) .layer(DefaultBodyLimit::max(10 * 1024 * 1024)) // 10 MB max request body (image uploads) .layer(cors) .layer(SetResponseHeaderLayer::if_not_present( header::X_CONTENT_TYPE_OPTIONS, header::HeaderValue::from_static("nosniff"), )) .layer(SetResponseHeaderLayer::if_not_present( header::X_FRAME_OPTIONS, header::HeaderValue::from_static("DENY"), )) .with_state(state.clone()); let (shutdown_tx, shutdown_rx) = oneshot::channel(); *state.shutdown_tx.write().await = Some(shutdown_tx); tokio::spawn(async move { if let Err(e) = axum::serve(listener, app) .with_graceful_shutdown(async { let _ = shutdown_rx.await; tracing::debug!("Web gateway shutting down"); }) .await { tracing::error!("Web gateway server error: {}", e); } }); Ok(bound_addr) } // --- Static file handlers --- async fn index_handler() -> impl IntoResponse { ( [ (header::CONTENT_TYPE, "text/html; charset=utf-8"), (header::CACHE_CONTROL, "no-cache"), ], include_str!("static/index.html"), ) } async fn css_handler() -> impl IntoResponse { ( [ (header::CONTENT_TYPE, "text/css"), (header::CACHE_CONTROL, "no-cache"), ], include_str!("static/style.css"), ) } async fn js_handler() -> impl IntoResponse { ( [ (header::CONTENT_TYPE, "application/javascript"), (header::CACHE_CONTROL, "no-cache"), ], include_str!("static/app.js"), ) } async fn favicon_handler() -> impl IntoResponse { ( [ (header::CONTENT_TYPE, "image/x-icon"), (header::CACHE_CONTROL, "public, max-age=86400"), ], include_bytes!("static/favicon.ico").as_slice(), ) } async fn i18n_index_handler() -> impl IntoResponse { ( [ (header::CONTENT_TYPE, "application/javascript"), (header::CACHE_CONTROL, "no-cache"), ], include_str!("static/i18n/index.js"), ) } async fn i18n_en_handler() -> impl IntoResponse { ( [ (header::CONTENT_TYPE, "application/javascript"), (header::CACHE_CONTROL, "no-cache"), ], include_str!("static/i18n/en.js"), ) } async fn i18n_zh_handler() -> impl IntoResponse { ( [ (header::CONTENT_TYPE, "application/javascript"), (header::CACHE_CONTROL, "no-cache"), ], include_str!("static/i18n/zh-CN.js"), ) } async fn i18n_app_handler() -> impl IntoResponse { ( [ (header::CONTENT_TYPE, "application/javascript"), (header::CACHE_CONTROL, "no-cache"), ], include_str!("static/i18n-app.js"), ) } // --- Health --- async fn health_handler() -> Json { Json(HealthResponse { status: "healthy", channel: "gateway", }) } /// Return an OAuth error landing page response. fn oauth_error_page(label: &str) -> axum::response::Response { let html = crate::cli::oauth_defaults::landing_html(label, false); axum::response::Html(html).into_response() } /// OAuth callback handler for the web gateway. /// /// This is a PUBLIC route (no Bearer token required) because OAuth providers /// redirect the user's browser here. The `state` query parameter correlates /// the callback with a pending OAuth flow registered by `start_wasm_oauth()`. /// /// Used on hosted instances where `IRONCLAW_OAUTH_CALLBACK_URL` points to /// the gateway (e.g., `https://kind-deer.agent1.near.ai/oauth/callback`). /// Local/desktop mode continues to use the TCP listener on port 9876. async fn oauth_callback_handler( State(state): State>, Query(params): Query>, ) -> impl IntoResponse { use crate::cli::oauth_defaults; // Check for error from OAuth provider (e.g., user denied consent) if let Some(error) = params.get("error") { let description = params .get("error_description") .cloned() .unwrap_or_else(|| error.clone()); return oauth_error_page(&description); } let state_param = match params.get("state") { Some(s) if !s.is_empty() => s.clone(), _ => return oauth_error_page("IronClaw"), }; let code = match params.get("code") { Some(c) if !c.is_empty() => c.clone(), _ => return oauth_error_page("IronClaw"), }; // Look up the pending flow by CSRF state (atomic remove prevents replay) let ext_mgr = match state.extension_manager.as_ref() { Some(mgr) => mgr, None => return oauth_error_page("IronClaw"), }; // Strip instance prefix from state for registry lookup. // Platform nginx sends `state=instance:nonce` but flows are keyed by nonce only. let lookup_key = oauth_defaults::strip_instance_prefix(&state_param); let flow = ext_mgr .pending_oauth_flows() .write() .await .remove(lookup_key); let flow = match flow { Some(f) => f, None => { tracing::warn!( state = %state_param, lookup_key = %lookup_key, "OAuth callback received with unknown or expired state" ); return oauth_error_page("IronClaw"); } }; // Check flow expiry (5 minutes, matching TCP listener timeout) if flow.created_at.elapsed() > oauth_defaults::OAUTH_FLOW_EXPIRY { tracing::warn!( extension = %flow.extension_name, "OAuth flow expired" ); return oauth_error_page(&flow.display_name); } // Exchange the authorization code for tokens. // Use the platform exchange proxy when configured (keeps client_secret off container), // otherwise call the provider's token URL directly. let exchange_proxy_url = std::env::var("IRONCLAW_OAUTH_EXCHANGE_URL").ok(); let result: Result<(), String> = async { let token_response = if let Some(ref proxy_url) = exchange_proxy_url { let gateway_token = flow.gateway_token.as_deref().unwrap_or_default(); oauth_defaults::exchange_via_proxy( proxy_url, gateway_token, &code, &flow.redirect_uri, flow.code_verifier.as_deref(), &flow.access_token_field, ) .await .map_err(|e| e.to_string())? } else { oauth_defaults::exchange_oauth_code( &flow.token_url, &flow.client_id, flow.client_secret.as_deref(), &code, &flow.redirect_uri, flow.code_verifier.as_deref(), &flow.access_token_field, ) .await .map_err(|e| e.to_string())? }; // Validate the token before storing (catches wrong account, etc.) if let Some(ref validation) = flow.validation_endpoint { oauth_defaults::validate_oauth_token(&token_response.access_token, validation) .await .map_err(|e| e.to_string())?; } // Store tokens encrypted in the secrets store oauth_defaults::store_oauth_tokens( flow.secrets.as_ref(), &flow.user_id, &flow.secret_name, flow.provider.as_deref(), &token_response.access_token, token_response.refresh_token.as_deref(), token_response.expires_in, &flow.scopes, ) .await .map_err(|e| e.to_string())?; Ok(()) } .await; let (success, message) = match &result { Ok(()) => ( true, format!("{} authenticated successfully", flow.display_name), ), Err(e) => ( false, format!("{} authentication failed: {}", flow.display_name, e), ), }; match &result { Ok(()) => { tracing::info!( extension = %flow.extension_name, "OAuth completed successfully via gateway callback" ); } Err(e) => { tracing::warn!( extension = %flow.extension_name, error = %e, "OAuth failed via gateway callback" ); } } // Broadcast SSE event to notify the web UI if let Some(ref sender) = flow.sse_sender { let _ = sender.send(SseEvent::AuthCompleted { extension_name: flow.extension_name, success, message, }); } let html = oauth_defaults::landing_html(&flow.display_name, success); axum::response::Html(html).into_response() } /// OAuth callback for Slack via channel-relay. /// /// This is a PUBLIC route (no Bearer token required) because channel-relay /// redirects the user's browser here after Slack OAuth completes. /// Query params: `stream_token`, `provider`, `team_id`. async fn slack_relay_oauth_callback_handler( State(state): State>, Query(params): Query>, ) -> impl IntoResponse { // Rate limit if !state.oauth_rate_limiter.check() { return axum::response::Html( "\

Too Many Requests

\

Please try again later.

\ " .to_string(), ) .into_response(); } // Validate stream_token: required, non-empty, max 2048 bytes let stream_token = match params.get("stream_token") { Some(t) if !t.is_empty() && t.len() <= 2048 => t.clone(), Some(t) if t.len() > 2048 => { return axum::response::Html( "\

Error

Invalid callback parameters.

" .to_string(), ) .into_response(); } _ => { return axum::response::Html( "\

Error

Invalid callback parameters.

" .to_string(), ) .into_response(); } }; // Validate team_id format: empty or T followed by alphanumeric (max 20 chars) let team_id = params.get("team_id").cloned().unwrap_or_default(); if !team_id.is_empty() { let valid_team_id = team_id.len() <= 21 && team_id.starts_with('T') && team_id[1..].chars().all(|c| c.is_ascii_alphanumeric()); if !valid_team_id { return axum::response::Html( "\

Error

Invalid callback parameters.

" .to_string(), ) .into_response(); } } // Validate provider: must be "slack" (only supported provider) let provider = params .get("provider") .cloned() .unwrap_or_else(|| "slack".into()); if provider != "slack" { return axum::response::Html( "\

Error

Invalid callback parameters.

" .to_string(), ) .into_response(); } let ext_mgr = match state.extension_manager.as_ref() { Some(mgr) => mgr, None => { return axum::response::Html( "\

Error

Extension manager not available.

" .to_string(), ) .into_response(); } }; // Validate CSRF state parameter let state_param = match params.get("state") { Some(s) if !s.is_empty() && s.len() <= 128 => s.clone(), _ => { return axum::response::Html( "\

Error

Invalid or expired authorization.

" .to_string(), ) .into_response(); } }; let state_key = format!("relay:{}:oauth_state", DEFAULT_RELAY_NAME); let stored_state = match ext_mgr .secrets() .get_decrypted(&state.user_id, &state_key) .await { Ok(secret) => secret.expose().to_string(), Err(_) => { return axum::response::Html( "\

Error

Invalid or expired authorization.

" .to_string(), ) .into_response(); } }; if state_param != stored_state { return axum::response::Html( "\

Error

Invalid or expired authorization.

" .to_string(), ) .into_response(); } // Delete the nonce (one-time use) let _ = ext_mgr.secrets().delete(&state.user_id, &state_key).await; let result: Result<(), String> = async { // Store the stream token as a secret let token_key = format!("relay:{}:stream_token", DEFAULT_RELAY_NAME); let _ = ext_mgr.secrets().delete(&state.user_id, &token_key).await; ext_mgr .secrets() .create( &state.user_id, crate::secrets::CreateSecretParams { name: token_key, value: secrecy::SecretString::from(stream_token), provider: Some(provider.clone()), expires_at: None, }, ) .await .map_err(|e| format!("Failed to store stream token: {}", e))?; // Store team_id in settings if let Some(ref store) = state.store { let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME); let _ = store .set_setting(&state.user_id, &team_id_key, &serde_json::json!(team_id)) .await; } // Activate the relay channel ext_mgr .activate_stored_relay(DEFAULT_RELAY_NAME) .await .map_err(|e| format!("Failed to activate relay channel: {}", e))?; Ok(()) } .await; let (success, message) = match &result { Ok(()) => (true, "Slack connected successfully!".to_string()), Err(e) => { tracing::error!(error = %e, "Slack relay OAuth callback failed"); ( false, "Connection failed. Check server logs for details.".to_string(), ) } }; // Broadcast SSE event to notify the web UI state.sse.broadcast(SseEvent::AuthCompleted { extension_name: DEFAULT_RELAY_NAME.to_string(), success, message: message.clone(), }); if success { axum::response::Html( "\

Slack Connected!

\

You can close this tab and return to IronClaw.

\ \ " .to_string(), ) .into_response() } else { axum::response::Html(format!( "\

Connection Failed

\

{}

\ ", message )) .into_response() } } // --- Chat handlers --- /// Convert web gateway `ImageData` to `IncomingAttachment` objects. pub(crate) fn images_to_attachments( images: &[ImageData], ) -> Vec { use base64::Engine; images .iter() .enumerate() .filter_map(|(i, img)| { if !img.media_type.starts_with("image/") { tracing::warn!( "Skipping image {i}: invalid media type '{}' (must start with 'image/')", img.media_type ); return None; } let data = match base64::engine::general_purpose::STANDARD.decode(&img.data) { Ok(d) => d, Err(e) => { tracing::warn!("Skipping image {i}: invalid base64 data: {e}"); return None; } }; Some(crate::channels::IncomingAttachment { id: format!("web-image-{i}"), kind: crate::channels::AttachmentKind::Image, mime_type: img.media_type.clone(), filename: Some(format!("image-{i}.{}", mime_to_ext(&img.media_type))), size_bytes: Some(data.len() as u64), source_url: None, storage_key: None, extracted_text: None, data, duration_secs: None, }) }) .collect() } /// Map MIME type to file extension. fn mime_to_ext(mime: &str) -> &str { match mime { "image/png" => "png", "image/gif" => "gif", "image/webp" => "webp", "image/svg+xml" => "svg", _ => "jpg", } } async fn chat_send_handler( State(state): State>, headers: axum::http::HeaderMap, Json(req): Json, ) -> Result<(StatusCode, Json), (StatusCode, String)> { tracing::trace!( "[chat_send_handler] Received message: content_len={}, thread_id={:?}", req.content.len(), req.thread_id ); if !state.chat_rate_limiter.check() { return Err(( StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded. Try again shortly.".to_string(), )); } let mut msg = IncomingMessage::new("gateway", &state.user_id, &req.content); // Prefer timezone from JSON body, fall back to X-Timezone header let tz = req .timezone .as_deref() .or_else(|| headers.get("X-Timezone").and_then(|v| v.to_str().ok())); if let Some(tz) = tz { msg = msg.with_timezone(tz); } if let Some(ref thread_id) = req.thread_id { msg = msg.with_thread(thread_id); msg = msg.with_metadata(serde_json::json!({"thread_id": thread_id})); } // Convert uploaded images to IncomingAttachments if !req.images.is_empty() { let attachments = images_to_attachments(&req.images); msg = msg.with_attachments(attachments); } let msg_id = msg.id; tracing::trace!( "[chat_send_handler] Created message id={}, content_len={}, images={}", msg_id, req.content.len(), req.images.len() ); let tx_guard = state.msg_tx.read().await; let tx = tx_guard.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Channel not started".to_string(), ))?; tracing::debug!("[chat_send_handler] Sending message through channel"); tx.send(msg).await.map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, "Channel closed".to_string(), ) })?; tracing::debug!("[chat_send_handler] Message sent successfully, returning 202 ACCEPTED"); Ok(( StatusCode::ACCEPTED, Json(SendMessageResponse { message_id: msg_id, status: "accepted", }), )) } async fn chat_approval_handler( State(state): State>, Json(req): Json, ) -> Result<(StatusCode, Json), (StatusCode, String)> { let (approved, always) = match req.action.as_str() { "approve" => (true, false), "always" => (true, true), "deny" => (false, false), other => { return Err(( StatusCode::BAD_REQUEST, format!("Unknown action: {}", other), )); } }; let request_id = Uuid::parse_str(&req.request_id).map_err(|_| { ( StatusCode::BAD_REQUEST, "Invalid request_id (expected UUID)".to_string(), ) })?; // Build a structured ExecApproval submission as JSON, sent through the // existing message pipeline so the agent loop picks it up. let approval = crate::agent::submission::Submission::ExecApproval { request_id, approved, always, }; let content = serde_json::to_string(&approval).map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to serialize approval: {}", e), ) })?; let mut msg = IncomingMessage::new("gateway", &state.user_id, content); if let Some(ref thread_id) = req.thread_id { msg = msg.with_thread(thread_id); } let msg_id = msg.id; let tx_guard = state.msg_tx.read().await; let tx = tx_guard.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Channel not started".to_string(), ))?; tx.send(msg).await.map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, "Channel closed".to_string(), ) })?; Ok(( StatusCode::ACCEPTED, Json(SendMessageResponse { message_id: msg_id, status: "accepted", }), )) } /// Submit an auth token directly to the extension manager, bypassing the message pipeline. /// /// The token never touches the LLM, chat history, or SSE stream. async fn chat_auth_token_handler( State(state): State>, Json(req): Json, ) -> Result, (StatusCode, String)> { let ext_mgr = state.extension_manager.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Extension manager not available".to_string(), ))?; match ext_mgr .configure_token(&req.extension_name, &req.token) .await { Ok(result) => { // Clear auth mode on the active thread clear_auth_mode(&state).await; state.sse.broadcast(SseEvent::AuthCompleted { extension_name: req.extension_name.clone(), success: true, message: result.message.clone(), }); Ok(Json(ActionResponse::ok(result.message))) } Err(e) => { let msg = e.to_string(); // Re-emit auth_required for retry on validation errors if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) { state.sse.broadcast(SseEvent::AuthRequired { extension_name: req.extension_name.clone(), instructions: Some(msg.clone()), auth_url: None, setup_url: None, }); } Ok(Json(ActionResponse::fail(msg))) } } } /// Cancel an in-progress auth flow. async fn chat_auth_cancel_handler( State(state): State>, Json(_req): Json, ) -> Result, (StatusCode, String)> { clear_auth_mode(&state).await; Ok(Json(ActionResponse::ok("Auth cancelled"))) } /// Clear pending auth mode on the active thread. pub async fn clear_auth_mode(state: &GatewayState) { if let Some(ref sm) = state.session_manager { let session = sm.get_or_create_session(&state.user_id).await; let mut sess = session.lock().await; if let Some(thread_id) = sess.active_thread && let Some(thread) = sess.threads.get_mut(&thread_id) { thread.pending_auth = None; } } } async fn chat_events_handler( State(state): State>, ) -> Result { let sse = state.sse.subscribe().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Too many connections".to_string(), ))?; Ok(( [("X-Accel-Buffering", "no"), ("Cache-Control", "no-cache")], sse, )) } async fn chat_ws_handler( headers: axum::http::HeaderMap, ws: WebSocketUpgrade, State(state): State>, ) -> Result { // Validate Origin header to prevent cross-site WebSocket hijacking. // Require the header outright; browsers always send it for WS upgrades, // so a missing Origin means a non-browser client trying to bypass the check. let origin = headers .get("origin") .and_then(|v| v.to_str().ok()) .ok_or_else(|| { ( StatusCode::FORBIDDEN, "WebSocket Origin header required".to_string(), ) })?; // Extract the host from the origin and compare exactly, so that // crafted origins like "http://localhost.evil.com" are rejected. // Origin format is "scheme://host[:port]". let host = origin .strip_prefix("http://") .or_else(|| origin.strip_prefix("https://")) .and_then(|rest| rest.split(':').next()?.split('/').next()) .unwrap_or(""); let is_local = matches!(host, "localhost" | "127.0.0.1" | "[::1]"); if !is_local { return Err(( StatusCode::FORBIDDEN, "WebSocket origin not allowed".to_string(), )); } Ok(ws.on_upgrade(move |socket| crate::channels::web::ws::handle_ws_connection(socket, state))) } #[derive(Deserialize)] struct HistoryQuery { thread_id: Option, limit: Option, before: Option, } async fn chat_history_handler( State(state): State>, Query(query): Query, ) -> Result, (StatusCode, String)> { let session_manager = state.session_manager.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Session manager not available".to_string(), ))?; let session = session_manager.get_or_create_session(&state.user_id).await; let sess = session.lock().await; let limit = query.limit.unwrap_or(50); let before_cursor = query .before .as_deref() .map(|s| { chrono::DateTime::parse_from_rfc3339(s) .map(|dt| dt.with_timezone(&chrono::Utc)) .map_err(|_| { ( StatusCode::BAD_REQUEST, "Invalid 'before' timestamp".to_string(), ) }) }) .transpose()?; // Find the thread let thread_id = if let Some(ref tid) = query.thread_id { Uuid::parse_str(tid) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid thread_id".to_string()))? } else { sess.active_thread .ok_or((StatusCode::NOT_FOUND, "No active thread".to_string()))? }; // Verify the thread belongs to the authenticated user before returning any data. // In-memory threads are already scoped by user via session_manager, but DB // lookups could expose another user's conversation if the UUID is guessed. if query.thread_id.is_some() && let Some(ref store) = state.store { let owned = store .conversation_belongs_to_user(thread_id, &state.user_id) .await .unwrap_or(false); if !owned && !sess.threads.contains_key(&thread_id) { return Err((StatusCode::NOT_FOUND, "Thread not found".to_string())); } } // For paginated requests (before cursor set), always go to DB if before_cursor.is_some() && let Some(ref store) = state.store { let (messages, has_more) = store .list_conversation_messages_paginated(thread_id, before_cursor, limit as i64) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let oldest_timestamp = messages.first().map(|m| m.created_at.to_rfc3339()); let turns = build_turns_from_db_messages(&messages); return Ok(Json(HistoryResponse { thread_id, turns, has_more, oldest_timestamp, pending_approval: None, })); } // Try in-memory first (freshest data for active threads) if let Some(thread) = sess.threads.get(&thread_id) && (!thread.turns.is_empty() || thread.pending_approval.is_some()) { let turns: Vec = thread .turns .iter() .map(|t| TurnInfo { turn_number: t.turn_number, user_input: t.user_input.clone(), response: t.response.clone(), state: format!("{:?}", t.state), started_at: t.started_at.to_rfc3339(), completed_at: t.completed_at.map(|dt| dt.to_rfc3339()), tool_calls: t .tool_calls .iter() .map(|tc| ToolCallInfo { name: tc.name.clone(), has_result: tc.result.is_some(), has_error: tc.error.is_some(), result_preview: tc.result.as_ref().map(|r| { let s = match r { serde_json::Value::String(s) => s.clone(), other => other.to_string(), }; truncate_preview(&s, 500) }), error: tc.error.clone(), }) .collect(), }) .collect(); let pending_approval = thread .pending_approval .as_ref() .map(|pa| PendingApprovalInfo { request_id: pa.request_id.to_string(), tool_name: pa.tool_name.clone(), description: pa.description.clone(), parameters: serde_json::to_string_pretty(&pa.parameters).unwrap_or_default(), }); return Ok(Json(HistoryResponse { thread_id, turns, has_more: false, oldest_timestamp: None, pending_approval, })); } // Fall back to DB for historical threads not in memory (paginated) if let Some(ref store) = state.store { let (messages, has_more) = store .list_conversation_messages_paginated(thread_id, None, limit as i64) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if !messages.is_empty() { let oldest_timestamp = messages.first().map(|m| m.created_at.to_rfc3339()); let turns = build_turns_from_db_messages(&messages); return Ok(Json(HistoryResponse { thread_id, turns, has_more, oldest_timestamp, pending_approval: None, })); } } // Empty thread (just created, no messages yet) Ok(Json(HistoryResponse { thread_id, turns: Vec::new(), has_more: false, oldest_timestamp: None, pending_approval: None, })) } async fn chat_threads_handler( State(state): State>, ) -> Result, (StatusCode, String)> { let session_manager = state.session_manager.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Session manager not available".to_string(), ))?; let session = session_manager.get_or_create_session(&state.user_id).await; let sess = session.lock().await; // Try DB first for persistent thread list if let Some(ref store) = state.store { // Auto-create assistant thread if it doesn't exist let assistant_id = store .get_or_create_assistant_conversation(&state.user_id, "gateway") .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if let Ok(summaries) = store .list_conversations_all_channels(&state.user_id, 50) .await { let mut assistant_thread = None; let mut threads = Vec::new(); for s in &summaries { let info = ThreadInfo { id: s.id, state: "Idle".to_string(), turn_count: s.message_count.max(0) as usize, created_at: s.started_at.to_rfc3339(), updated_at: s.last_activity.to_rfc3339(), title: s.title.clone(), thread_type: s.thread_type.clone(), channel: Some(s.channel.clone()), }; if s.id == assistant_id { assistant_thread = Some(info); } else { threads.push(info); } } // If assistant wasn't in the list (0 messages), synthesize it if assistant_thread.is_none() { assistant_thread = Some(ThreadInfo { id: assistant_id, state: "Idle".to_string(), turn_count: 0, created_at: chrono::Utc::now().to_rfc3339(), updated_at: chrono::Utc::now().to_rfc3339(), title: None, thread_type: Some("assistant".to_string()), channel: Some("gateway".to_string()), }); } return Ok(Json(ThreadListResponse { assistant_thread, threads, active_thread: sess.active_thread, })); } } // Fallback: in-memory only (no assistant thread without DB) let mut sorted_threads: Vec<_> = sess.threads.values().collect(); sorted_threads.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); let threads: Vec = sorted_threads .into_iter() .map(|t| ThreadInfo { id: t.id, state: format!("{:?}", t.state), turn_count: t.turns.len(), created_at: t.created_at.to_rfc3339(), updated_at: t.updated_at.to_rfc3339(), title: None, thread_type: None, channel: Some("gateway".to_string()), }) .collect(); Ok(Json(ThreadListResponse { assistant_thread: None, threads, active_thread: sess.active_thread, })) } async fn chat_new_thread_handler( State(state): State>, ) -> Result, (StatusCode, String)> { let session_manager = state.session_manager.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Session manager not available".to_string(), ))?; let session = session_manager.get_or_create_session(&state.user_id).await; let (thread_id, info) = { let mut sess = session.lock().await; let thread = sess.create_thread(); let id = thread.id; let info = ThreadInfo { id: thread.id, state: format!("{:?}", thread.state), turn_count: thread.turns.len(), created_at: thread.created_at.to_rfc3339(), updated_at: thread.updated_at.to_rfc3339(), title: None, thread_type: Some("thread".to_string()), channel: Some("gateway".to_string()), }; (id, info) }; // Persist the empty conversation row with thread_type metadata synchronously // so that the subsequent loadThreads() call from the frontend sees it. if let Some(ref store) = state.store { if let Err(e) = store .ensure_conversation(thread_id, "gateway", &state.user_id, None) .await { tracing::warn!("Failed to persist new thread: {}", e); } let metadata_val = serde_json::json!("thread"); if let Err(e) = store .update_conversation_metadata_field(thread_id, "thread_type", &metadata_val) .await { tracing::warn!("Failed to set thread_type metadata: {}", e); } } Ok(Json(info)) } // --- Memory handlers --- #[derive(Deserialize)] struct TreeQuery { #[allow(dead_code)] depth: Option, } async fn memory_tree_handler( State(state): State>, Query(_query): Query, ) -> Result, (StatusCode, String)> { let workspace = state.workspace.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Workspace not available".to_string(), ))?; // Build tree from list_all (flat list of all paths) let all_paths = workspace .list_all() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Collect unique directories and files let mut entries: Vec = Vec::new(); let mut seen_dirs: std::collections::HashSet = std::collections::HashSet::new(); for path in &all_paths { // Add parent directories let parts: Vec<&str> = path.split('/').collect(); for i in 0..parts.len().saturating_sub(1) { let dir_path = parts[..=i].join("/"); if seen_dirs.insert(dir_path.clone()) { entries.push(TreeEntry { path: dir_path, is_dir: true, }); } } // Add the file itself entries.push(TreeEntry { path: path.clone(), is_dir: false, }); } entries.sort_by(|a, b| a.path.cmp(&b.path)); Ok(Json(MemoryTreeResponse { entries })) } #[derive(Deserialize)] struct ListQuery { path: Option, } async fn memory_list_handler( State(state): State>, Query(query): Query, ) -> Result, (StatusCode, String)> { let workspace = state.workspace.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Workspace not available".to_string(), ))?; let path = query.path.as_deref().unwrap_or(""); let entries = workspace .list(path) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let list_entries: Vec = entries .iter() .map(|e| ListEntry { name: e.path.rsplit('/').next().unwrap_or(&e.path).to_string(), path: e.path.clone(), is_dir: e.is_directory, updated_at: e.updated_at.map(|dt| dt.to_rfc3339()), }) .collect(); Ok(Json(MemoryListResponse { path: path.to_string(), entries: list_entries, })) } #[derive(Deserialize)] struct ReadQuery { path: String, } async fn memory_read_handler( State(state): State>, Query(query): Query, ) -> Result, (StatusCode, String)> { let workspace = state.workspace.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Workspace not available".to_string(), ))?; let doc = workspace .read(&query.path) .await .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?; Ok(Json(MemoryReadResponse { path: query.path, content: doc.content, updated_at: Some(doc.updated_at.to_rfc3339()), })) } async fn memory_write_handler( State(state): State>, Json(req): Json, ) -> Result, (StatusCode, String)> { let workspace = state.workspace.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Workspace not available".to_string(), ))?; workspace .write(&req.path, &req.content) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(MemoryWriteResponse { path: req.path, status: "written", })) } async fn memory_search_handler( State(state): State>, Json(req): Json, ) -> Result, (StatusCode, String)> { let workspace = state.workspace.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Workspace not available".to_string(), ))?; let limit = req.limit.unwrap_or(10); let results = workspace .search(&req.query, limit) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let hits: Vec = results .iter() .map(|r| SearchHit { path: r.document_id.to_string(), content: r.content.clone(), score: r.score as f64, }) .collect(); Ok(Json(MemorySearchResponse { results: hits })) } // Job handlers moved to handlers/jobs.rs // --- Logs handlers --- async fn logs_events_handler( State(state): State>, ) -> Result { let broadcaster = state.log_broadcaster.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Log broadcaster not available".to_string(), ))?; // Replay recent history so late-joining browsers see startup logs. // Subscribe BEFORE snapshotting to avoid a gap between history and live. let rx = broadcaster.subscribe(); let history = broadcaster.recent_entries(); let history_stream = futures::stream::iter(history).map(|entry| { let data = serde_json::to_string(&entry).unwrap_or_default(); Ok::<_, Infallible>(Event::default().event("log").data(data)) }); let live_stream = tokio_stream::wrappers::BroadcastStream::new(rx) .filter_map(|result| result.ok()) .map(|entry| { let data = serde_json::to_string(&entry).unwrap_or_default(); Ok::<_, Infallible>(Event::default().event("log").data(data)) }); let stream = history_stream.chain(live_stream); Ok(( [("X-Accel-Buffering", "no"), ("Cache-Control", "no-cache")], Sse::new(stream).keep_alive( KeepAlive::new() .interval(std::time::Duration::from_secs(30)) .text(""), ), )) } async fn logs_level_get_handler( State(state): State>, ) -> Result, (StatusCode, String)> { let handle = state.log_level_handle.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Log level control not available".to_string(), ))?; Ok(Json(serde_json::json!({ "level": handle.current_level() }))) } async fn logs_level_set_handler( State(state): State>, Json(body): Json, ) -> Result, (StatusCode, String)> { let handle = state.log_level_handle.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Log level control not available".to_string(), ))?; let level = body .get("level") .and_then(|v| v.as_str()) .ok_or((StatusCode::BAD_REQUEST, "missing 'level' field".to_string()))?; handle .set_level(level) .map_err(|e| (StatusCode::BAD_REQUEST, e))?; tracing::info!("Log level changed to '{}'", handle.current_level()); Ok(Json(serde_json::json!({ "level": handle.current_level() }))) } // --- Extension handlers --- async fn extensions_list_handler( State(state): State>, ) -> Result, (StatusCode, String)> { let ext_mgr = state.extension_manager.as_ref().ok_or(( StatusCode::NOT_IMPLEMENTED, "Extension manager not available (secrets store required)".to_string(), ))?; let installed = ext_mgr .list(None, false) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let pairing_store = crate::pairing::PairingStore::new(); let extensions = installed .into_iter() .map(|ext| { let activation_status = if ext.kind == crate::extensions::ExtensionKind::WasmChannel { Some(if ext.activation_error.is_some() { "failed".to_string() } else if !ext.authenticated { // No credentials configured yet. "installed".to_string() } else if ext.active { // Check pairing status for active channels. let has_paired = pairing_store .read_allow_from(&ext.name) .map(|list| !list.is_empty()) .unwrap_or(false); if has_paired { "active".to_string() } else { "pairing".to_string() } } else { // Authenticated but not yet active. "configured".to_string() }) } else { None }; ExtensionInfo { name: ext.name, display_name: ext.display_name, kind: ext.kind.to_string(), description: ext.description, url: ext.url, authenticated: ext.authenticated, active: ext.active, tools: ext.tools, needs_setup: ext.needs_setup, has_auth: ext.has_auth, activation_status, activation_error: ext.activation_error, version: ext.version, } }) .collect(); Ok(Json(ExtensionListResponse { extensions })) } async fn extensions_tools_handler( State(state): State>, ) -> Result, (StatusCode, String)> { let registry = state.tool_registry.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Tool registry not available".to_string(), ))?; let definitions = registry.tool_definitions().await; let tools = definitions .into_iter() .map(|td| ToolInfo { name: td.name, description: td.description, }) .collect(); Ok(Json(ToolListResponse { tools })) } async fn extensions_install_handler( State(state): State>, Json(req): Json, ) -> Result, (StatusCode, String)> { // When extension manager isn't available, check registry entries for a helpful message let Some(ext_mgr) = state.extension_manager.as_ref() else { // Look up the entry in the catalog to give a specific error if let Some(entry) = state.registry_entries.iter().find(|e| e.name == req.name) { let msg = match &entry.source { crate::extensions::ExtensionSource::WasmBuildable { .. } => { format!( "'{}' requires building from source. \ Run `ironclaw registry install {}` from the CLI.", req.name, req.name ) } _ => format!( "Extension manager not available (secrets store required). \ Configure DATABASE_URL or a secrets backend to enable installation of '{}'.", req.name ), }; return Ok(Json(ActionResponse::fail(msg))); } return Ok(Json(ActionResponse::fail( "Extension manager not available (secrets store required)".to_string(), ))); }; let kind_hint = req.kind.as_deref().and_then(|k| match k { "mcp_server" => Some(crate::extensions::ExtensionKind::McpServer), "wasm_tool" => Some(crate::extensions::ExtensionKind::WasmTool), "wasm_channel" => Some(crate::extensions::ExtensionKind::WasmChannel), _ => None, }); match ext_mgr .install(&req.name, req.url.as_deref(), kind_hint) .await { Ok(result) => { let mut resp = ActionResponse::ok(result.message); // Auto-activate WASM tools after install (install = active). if result.kind == crate::extensions::ExtensionKind::WasmTool { if let Err(e) = ext_mgr.activate(&req.name).await { tracing::debug!( extension = %req.name, error = %e, "Auto-activation after install failed" ); } // Check auth after activation. This may initiate OAuth both for scope // expansion and for first-time auth when credentials are already // configured (e.g., built-in providers). We only surface an auth_url // when the extension reports it is awaiting authorization. match ext_mgr.auth(&req.name).await { Ok(auth_result) if auth_result.auth_url().is_some() => { // Scope expansion or initial OAuth: user needs to authorize resp.auth_url = auth_result.auth_url().map(String::from); } _ => {} } } Ok(Json(resp)) } Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))), } } async fn extensions_activate_handler( State(state): State>, Path(name): Path, ) -> Result, (StatusCode, String)> { let ext_mgr = state.extension_manager.as_ref().ok_or(( StatusCode::NOT_IMPLEMENTED, "Extension manager not available (secrets store required)".to_string(), ))?; match ext_mgr.activate(&name).await { Ok(result) => { // Activation loaded the WASM module. Check if the tool needs // OAuth scope expansion (e.g., adding google-docs when gmail // already has a token but missing the documents scope). // Initial OAuth setup is triggered via configure. let mut resp = ActionResponse::ok(result.message); if let Ok(auth_result) = ext_mgr.auth(&name).await && auth_result.auth_url().is_some() { resp.auth_url = auth_result.auth_url().map(String::from); } Ok(Json(resp)) } Err(activate_err) => { let needs_auth = matches!( &activate_err, crate::extensions::ExtensionError::AuthRequired ); if !needs_auth { return Ok(Json(ActionResponse::fail(activate_err.to_string()))); } // Activation failed due to auth; try authenticating first. match ext_mgr.auth(&name).await { Ok(auth_result) if auth_result.is_authenticated() => { // Auth succeeded, retry activation. match ext_mgr.activate(&name).await { Ok(result) => Ok(Json(ActionResponse::ok(result.message))), Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))), } } Ok(auth_result) => { // Auth in progress (OAuth URL or awaiting manual token). let mut resp = ActionResponse::fail( auth_result .instructions() .map(String::from) .unwrap_or_else(|| format!("'{}' requires authentication.", name)), ); resp.auth_url = auth_result.auth_url().map(String::from); resp.awaiting_token = Some(auth_result.is_awaiting_token()); resp.instructions = auth_result.instructions().map(String::from); Ok(Json(resp)) } Err(auth_err) => Ok(Json(ActionResponse::fail(format!( "Authentication failed: {}", auth_err )))), } } } } // --- Project file serving handlers --- /// Redirect `/projects/{id}` to `/projects/{id}/` so relative paths in /// the served HTML resolve within the project namespace. async fn project_redirect_handler(Path(project_id): Path) -> impl IntoResponse { axum::response::Redirect::permanent(&format!("/projects/{project_id}/")) } /// Serve `index.html` when hitting `/projects/{project_id}/`. async fn project_index_handler(Path(project_id): Path) -> impl IntoResponse { serve_project_file(&project_id, "index.html").await } /// Serve any file under `/projects/{project_id}/{path}`. async fn project_file_handler( Path((project_id, path)): Path<(String, String)>, ) -> impl IntoResponse { serve_project_file(&project_id, &path).await } /// Shared logic: resolve the file inside `~/.ironclaw/projects/{project_id}/`, /// guard against path traversal, and stream the content with the right MIME type. async fn serve_project_file(project_id: &str, path: &str) -> axum::response::Response { // Reject project_id values that could escape the projects directory. if project_id.contains('/') || project_id.contains('\\') || project_id.contains("..") || project_id.is_empty() { return (StatusCode::BAD_REQUEST, "Invalid project ID").into_response(); } let base = ironclaw_base_dir().join("projects").join(project_id); let file_path = base.join(path); // Path traversal guard let canonical = match file_path.canonicalize() { Ok(p) => p, Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), }; let base_canonical = match base.canonicalize() { Ok(p) => p, Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), }; if !canonical.starts_with(&base_canonical) { return (StatusCode::FORBIDDEN, "Forbidden").into_response(); } match tokio::fs::read(&canonical).await { Ok(contents) => { let mime = mime_guess::from_path(&canonical) .first_or_octet_stream() .to_string(); ([(header::CONTENT_TYPE, mime)], contents).into_response() } Err(_) => (StatusCode::NOT_FOUND, "Not found").into_response(), } } async fn extensions_remove_handler( State(state): State>, Path(name): Path, ) -> Result, (StatusCode, String)> { let ext_mgr = state.extension_manager.as_ref().ok_or(( StatusCode::NOT_IMPLEMENTED, "Extension manager not available (secrets store required)".to_string(), ))?; match ext_mgr.remove(&name).await { Ok(message) => Ok(Json(ActionResponse::ok(message))), Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))), } } async fn extensions_registry_handler( State(state): State>, Query(params): Query, ) -> Json { let query = params.query.unwrap_or_default(); let query_lower = query.to_lowercase(); let tokens: Vec<&str> = query_lower.split_whitespace().collect(); // Filter registry entries by query (or return all if empty) let matching: Vec<&crate::extensions::RegistryEntry> = if tokens.is_empty() { state.registry_entries.iter().collect() } else { state .registry_entries .iter() .filter(|e| { let name = e.name.to_lowercase(); let display = e.display_name.to_lowercase(); let desc = e.description.to_lowercase(); tokens.iter().any(|t| { name.contains(t) || display.contains(t) || desc.contains(t) || e.keywords.iter().any(|k| k.to_lowercase().contains(t)) }) }) .collect() }; // Cross-reference with installed extensions by (name, kind) to avoid // false positives when the same name exists as different kinds. let installed: std::collections::HashSet<(String, String)> = if let Some(ext_mgr) = state.extension_manager.as_ref() { ext_mgr .list(None, false) .await .unwrap_or_default() .into_iter() .map(|ext| (ext.name, ext.kind.to_string())) .collect() } else { std::collections::HashSet::new() }; let entries = matching .into_iter() .map(|e| { let kind_str = e.kind.to_string(); RegistryEntryInfo { name: e.name.clone(), display_name: e.display_name.clone(), installed: installed.contains(&(e.name.clone(), kind_str.clone())), kind: kind_str, description: e.description.clone(), keywords: e.keywords.clone(), version: e.version.clone(), } }) .collect(); Json(RegistrySearchResponse { entries }) } async fn extensions_setup_handler( State(state): State>, Path(name): Path, ) -> Result, (StatusCode, String)> { let ext_mgr = state.extension_manager.as_ref().ok_or(( StatusCode::NOT_IMPLEMENTED, "Extension manager not available (secrets store required)".to_string(), ))?; let secrets = ext_mgr .get_setup_schema(&name) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let kind = ext_mgr .list(None, false) .await .ok() .and_then(|list| list.into_iter().find(|e| e.name == name)) .map(|e| e.kind.to_string()) .unwrap_or_default(); Ok(Json(ExtensionSetupResponse { name, kind, secrets, })) } async fn extensions_setup_submit_handler( State(state): State>, Path(name): Path, Json(req): Json, ) -> Result, (StatusCode, String)> { let ext_mgr = state.extension_manager.as_ref().ok_or(( StatusCode::NOT_IMPLEMENTED, "Extension manager not available (secrets store required)".to_string(), ))?; match ext_mgr.configure(&name, &req.secrets).await { Ok(result) => { // Broadcast auth_completed so the chat UI can dismiss any in-progress // auth card or setup modal that was triggered by tool_auth/tool_activate. state.sse.broadcast(SseEvent::AuthCompleted { extension_name: name.clone(), success: true, message: result.message.clone(), }); let mut resp = ActionResponse::ok(result.message); resp.activated = Some(result.activated); resp.auth_url = result.auth_url; Ok(Json(resp)) } Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))), } } // --- Pairing handlers --- async fn pairing_list_handler( Path(channel): Path, ) -> Result, (StatusCode, String)> { let store = crate::pairing::PairingStore::new(); let requests = store .list_pending(&channel) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let infos = requests .into_iter() .map(|r| PairingRequestInfo { code: r.code, sender_id: r.id, meta: r.meta, created_at: r.created_at, }) .collect(); Ok(Json(PairingListResponse { channel, requests: infos, })) } async fn pairing_approve_handler( Path(channel): Path, Json(req): Json, ) -> Result, (StatusCode, String)> { let store = crate::pairing::PairingStore::new(); match store.approve(&channel, &req.code) { Ok(Some(approved)) => Ok(Json(ActionResponse::ok(format!( "Pairing approved for sender '{}'", approved.id )))), Ok(None) => Ok(Json(ActionResponse::fail( "Invalid or expired pairing code".to_string(), ))), Err(crate::pairing::PairingStoreError::ApproveRateLimited) => Err(( StatusCode::TOO_MANY_REQUESTS, "Too many failed approve attempts; try again later".to_string(), )), Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))), } } // --- Routines handlers --- async fn routines_list_handler( State(state): State>, ) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), ))?; let routines = store .list_all_routines() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let items: Vec = routines.iter().map(RoutineInfo::from_routine).collect(); Ok(Json(RoutineListResponse { routines: items })) } async fn routines_summary_handler( State(state): State>, ) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), ))?; let routines = store .list_all_routines() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let total = routines.len() as u64; let enabled = routines.iter().filter(|r| r.enabled).count() as u64; let disabled = total - enabled; let failing = routines .iter() .filter(|r| r.consecutive_failures > 0) .count() as u64; let today_start = chrono::Utc::now() .date_naive() .and_hms_opt(0, 0, 0) .map(|dt| dt.and_utc()); let runs_today = if let Some(start) = today_start { routines .iter() .filter(|r| r.last_run_at.is_some_and(|ts| ts >= start)) .count() as u64 } else { 0 }; Ok(Json(RoutineSummaryResponse { total, enabled, disabled, failing, runs_today, })) } async fn routines_detail_handler( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), ))?; let routine_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?; let routine = store .get_routine(routine_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?; let runs = store .list_routine_runs(routine_id, 20) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let recent_runs: Vec = runs .iter() .map(|run| RoutineRunInfo { id: run.id, trigger_type: run.trigger_type.clone(), started_at: run.started_at.to_rfc3339(), completed_at: run.completed_at.map(|dt| dt.to_rfc3339()), status: format!("{:?}", run.status), result_summary: run.result_summary.clone(), tokens_used: run.tokens_used, job_id: run.job_id, }) .collect(); Ok(Json(RoutineDetailResponse { id: routine.id, name: routine.name.clone(), description: routine.description.clone(), enabled: routine.enabled, trigger: serde_json::to_value(&routine.trigger).unwrap_or_default(), action: serde_json::to_value(&routine.action).unwrap_or_default(), guardrails: serde_json::to_value(&routine.guardrails).unwrap_or_default(), notify: serde_json::to_value(&routine.notify).unwrap_or_default(), last_run_at: routine.last_run_at.map(|dt| dt.to_rfc3339()), next_fire_at: routine.next_fire_at.map(|dt| dt.to_rfc3339()), run_count: routine.run_count, consecutive_failures: routine.consecutive_failures, created_at: routine.created_at.to_rfc3339(), recent_runs, })) } async fn routines_trigger_handler( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { let engine = { let guard = state.routine_engine.read().await; guard.as_ref().cloned().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Routine engine not available".to_string(), ))? }; let routine_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?; let run_id = engine .fire_manual(routine_id, Some(&state.user_id)) .await .map_err(|e| { let status = match &e { crate::error::RoutineError::NotFound { .. } => StatusCode::NOT_FOUND, crate::error::RoutineError::NotAuthorized { .. } => StatusCode::FORBIDDEN, crate::error::RoutineError::Disabled { .. } | crate::error::RoutineError::MaxConcurrent { .. } => StatusCode::CONFLICT, _ => StatusCode::INTERNAL_SERVER_ERROR, }; (status, e.to_string()) })?; Ok(Json(serde_json::json!({ "status": "triggered", "routine_id": routine_id, "run_id": run_id, }))) } #[derive(Deserialize)] struct ToggleRequest { enabled: Option, } async fn routines_toggle_handler( State(state): State>, Path(id): Path, body: Option>, ) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), ))?; let routine_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?; let mut routine = store .get_routine(routine_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?; // If a specific value was provided, use it; otherwise toggle. routine.enabled = match body { Some(Json(req)) => req.enabled.unwrap_or(!routine.enabled), None => !routine.enabled, }; store .update_routine(&routine) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(serde_json::json!({ "status": if routine.enabled { "enabled" } else { "disabled" }, "routine_id": routine_id, }))) } async fn routines_delete_handler( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), ))?; let routine_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?; let deleted = store .delete_routine(routine_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if deleted { Ok(Json(serde_json::json!({ "status": "deleted", "routine_id": routine_id, }))) } else { Err((StatusCode::NOT_FOUND, "Routine not found".to_string())) } } async fn routines_runs_handler( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), ))?; let routine_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?; let runs = store .list_routine_runs(routine_id, 50) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let run_infos: Vec = runs .iter() .map(|run| RoutineRunInfo { id: run.id, trigger_type: run.trigger_type.clone(), started_at: run.started_at.to_rfc3339(), completed_at: run.completed_at.map(|dt| dt.to_rfc3339()), status: format!("{:?}", run.status), result_summary: run.result_summary.clone(), tokens_used: run.tokens_used, job_id: run.job_id, }) .collect(); Ok(Json(serde_json::json!({ "routine_id": routine_id, "runs": run_infos, }))) } // --- Settings handlers --- async fn settings_list_handler( State(state): State>, ) -> Result, StatusCode> { let store = state .store .as_ref() .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; let rows = store.list_settings(&state.user_id).await.map_err(|e| { tracing::error!("Failed to list settings: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; let settings = rows .into_iter() .map(|r| SettingResponse { key: r.key, value: r.value, updated_at: r.updated_at.to_rfc3339(), }) .collect(); Ok(Json(SettingsListResponse { settings })) } async fn settings_get_handler( State(state): State>, Path(key): Path, ) -> Result, StatusCode> { let store = state .store .as_ref() .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; let row = store .get_setting_full(&state.user_id, &key) .await .map_err(|e| { tracing::error!("Failed to get setting '{}': {}", key, e); StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(StatusCode::NOT_FOUND)?; Ok(Json(SettingResponse { key: row.key, value: row.value, updated_at: row.updated_at.to_rfc3339(), })) } async fn settings_set_handler( State(state): State>, Path(key): Path, Json(body): Json, ) -> Result { let store = state .store .as_ref() .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; store .set_setting(&state.user_id, &key, &body.value) .await .map_err(|e| { tracing::error!("Failed to set setting '{}': {}", key, e); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(StatusCode::NO_CONTENT) } async fn settings_delete_handler( State(state): State>, Path(key): Path, ) -> Result { let store = state .store .as_ref() .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; store .delete_setting(&state.user_id, &key) .await .map_err(|e| { tracing::error!("Failed to delete setting '{}': {}", key, e); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(StatusCode::NO_CONTENT) } async fn settings_export_handler( State(state): State>, ) -> Result, StatusCode> { let store = state .store .as_ref() .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; let settings = store.get_all_settings(&state.user_id).await.map_err(|e| { tracing::error!("Failed to export settings: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(SettingsExportResponse { settings })) } async fn settings_import_handler( State(state): State>, Json(body): Json, ) -> Result { let store = state .store .as_ref() .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; store .set_all_settings(&state.user_id, &body.settings) .await .map_err(|e| { tracing::error!("Failed to import settings: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(StatusCode::NO_CONTENT) } // --- Gateway control plane handlers --- async fn gateway_status_handler( State(state): State>, ) -> Json { let sse_connections = state.sse.connection_count(); let ws_connections = state .ws_tracker .as_ref() .map(|t| t.connection_count()) .unwrap_or(0); let uptime_secs = state.startup_time.elapsed().as_secs(); let (daily_cost, actions_this_hour, model_usage) = if let Some(ref cg) = state.cost_guard { let cost = cg.daily_spend().await; let actions = cg.actions_this_hour().await; let usage = cg.model_usage().await; let models: Vec = usage .into_iter() .map(|(model, tokens)| ModelUsageEntry { model, input_tokens: tokens.input_tokens, output_tokens: tokens.output_tokens, cost: format!("{:.6}", tokens.cost), }) .collect(); (Some(format!("{:.4}", cost)), Some(actions), Some(models)) } else { (None, None, None) }; let restart_enabled = std::env::var("IRONCLAW_IN_DOCKER") .map(|v| v.to_lowercase() == "true") .unwrap_or(false); Json(GatewayStatusResponse { version: env!("CARGO_PKG_VERSION").to_string(), sse_connections, ws_connections, total_connections: sse_connections + ws_connections, uptime_secs, restart_enabled, daily_cost, actions_this_hour, model_usage, }) } #[derive(serde::Serialize)] struct ModelUsageEntry { model: String, input_tokens: u64, output_tokens: u64, cost: String, } #[derive(serde::Serialize)] struct GatewayStatusResponse { version: String, sse_connections: u64, ws_connections: u64, total_connections: u64, uptime_secs: u64, restart_enabled: bool, #[serde(skip_serializing_if = "Option::is_none")] daily_cost: Option, #[serde(skip_serializing_if = "Option::is_none")] actions_this_hour: Option, #[serde(skip_serializing_if = "Option::is_none")] model_usage: Option>, } #[cfg(test)] mod tests { use super::*; use crate::testing::credentials::TEST_GATEWAY_CRYPTO_KEY; #[test] fn test_build_turns_from_db_messages_complete() { let now = chrono::Utc::now(); let messages = vec![ crate::history::ConversationMessage { id: Uuid::new_v4(), role: "user".to_string(), content: "Hello".to_string(), created_at: now, }, crate::history::ConversationMessage { id: Uuid::new_v4(), role: "assistant".to_string(), content: "Hi there!".to_string(), created_at: now + chrono::TimeDelta::seconds(1), }, crate::history::ConversationMessage { id: Uuid::new_v4(), role: "user".to_string(), content: "How are you?".to_string(), created_at: now + chrono::TimeDelta::seconds(2), }, crate::history::ConversationMessage { id: Uuid::new_v4(), role: "assistant".to_string(), content: "Doing well!".to_string(), created_at: now + chrono::TimeDelta::seconds(3), }, ]; let turns = build_turns_from_db_messages(&messages); assert_eq!(turns.len(), 2); assert_eq!(turns[0].user_input, "Hello"); assert_eq!(turns[0].response.as_deref(), Some("Hi there!")); assert_eq!(turns[0].state, "Completed"); assert_eq!(turns[1].user_input, "How are you?"); assert_eq!(turns[1].response.as_deref(), Some("Doing well!")); } #[test] fn test_build_turns_from_db_messages_incomplete_last() { let now = chrono::Utc::now(); let messages = vec![ crate::history::ConversationMessage { id: Uuid::new_v4(), role: "user".to_string(), content: "Hello".to_string(), created_at: now, }, crate::history::ConversationMessage { id: Uuid::new_v4(), role: "assistant".to_string(), content: "Hi!".to_string(), created_at: now + chrono::TimeDelta::seconds(1), }, crate::history::ConversationMessage { id: Uuid::new_v4(), role: "user".to_string(), content: "Lost message".to_string(), created_at: now + chrono::TimeDelta::seconds(2), }, ]; let turns = build_turns_from_db_messages(&messages); assert_eq!(turns.len(), 2); assert_eq!(turns[1].user_input, "Lost message"); assert!(turns[1].response.is_none()); assert_eq!(turns[1].state, "Failed"); } #[test] fn test_build_turns_from_db_messages_empty() { let turns = build_turns_from_db_messages(&[]); assert!(turns.is_empty()); } // --- OAuth callback handler tests --- /// Build a minimal `GatewayState` for testing the OAuth callback handler. fn test_gateway_state(ext_mgr: Option>) -> Arc { Arc::new(GatewayState { msg_tx: tokio::sync::RwLock::new(None), sse: SseManager::new(), workspace: None, session_manager: None, log_broadcaster: None, log_level_handle: None, extension_manager: ext_mgr, tool_registry: None, store: None, job_manager: None, prompt_queue: None, user_id: "test".to_string(), shutdown_tx: tokio::sync::RwLock::new(None), ws_tracker: None, llm_provider: None, skill_registry: None, skill_catalog: None, scheduler: None, chat_rate_limiter: RateLimiter::new(30, 60), oauth_rate_limiter: RateLimiter::new(10, 60), registry_entries: vec![], cost_guard: None, routine_engine: Arc::new(tokio::sync::RwLock::new(None)), startup_time: std::time::Instant::now(), }) } /// Build a test router with just the OAuth callback route. fn test_oauth_router(state: Arc) -> Router { Router::new() .route("/oauth/callback", get(oauth_callback_handler)) .with_state(state) } #[tokio::test] async fn test_oauth_callback_missing_params() { use axum::body::Body; use tower::ServiceExt; let state = test_gateway_state(None); let app = test_oauth_router(state); let req = axum::http::Request::builder() .uri("/oauth/callback") .body(Body::empty()) .expect("request"); let resp = ServiceExt::>::oneshot(app, req) .await .expect("response"); assert_eq!(resp.status(), StatusCode::OK); let body = axum::body::to_bytes(resp.into_body(), 1024 * 64) .await .expect("body"); let html = String::from_utf8_lossy(&body); assert!(html.contains("Authorization Failed")); } #[tokio::test] async fn test_oauth_callback_error_from_provider() { use axum::body::Body; use tower::ServiceExt; let state = test_gateway_state(None); let app = test_oauth_router(state); let req = axum::http::Request::builder() .uri("/oauth/callback?error=access_denied&error_description=access_denied") .body(Body::empty()) .expect("request"); let resp = ServiceExt::>::oneshot(app, req) .await .expect("response"); assert_eq!(resp.status(), StatusCode::OK); let body = axum::body::to_bytes(resp.into_body(), 1024 * 64) .await .expect("body"); let html = String::from_utf8_lossy(&body); assert!(html.contains("Authorization Failed")); } #[tokio::test] async fn test_oauth_callback_unknown_state() { use axum::body::Body; use tower::ServiceExt; // Build an ExtensionManager so the handler can look up flows let secrets = Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new( crate::secrets::SecretsCrypto::new(secrecy::SecretString::from( TEST_GATEWAY_CRYPTO_KEY.to_string(), )) .expect("crypto"), ))); let tool_registry = Arc::new(ToolRegistry::new()); let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new()); let ext_mgr = Arc::new(ExtensionManager::new( mcp_sm, Arc::new(crate::tools::mcp::process::McpProcessManager::new()), secrets, tool_registry, None, None, std::path::PathBuf::from("/tmp/wasm_tools"), std::path::PathBuf::from("/tmp/wasm_channels"), None, "test".to_string(), None, vec![], )); let state = test_gateway_state(Some(ext_mgr)); let app = test_oauth_router(state); let req = axum::http::Request::builder() .uri("/oauth/callback?code=test_code&state=unknown_state_value") .body(Body::empty()) .expect("request"); let resp = ServiceExt::>::oneshot(app, req) .await .expect("response"); assert_eq!(resp.status(), StatusCode::OK); let body = axum::body::to_bytes(resp.into_body(), 1024 * 64) .await .expect("body"); let html = String::from_utf8_lossy(&body); assert!(html.contains("Authorization Failed")); } #[tokio::test] async fn test_oauth_callback_expired_flow() { use axum::body::Body; use tower::ServiceExt; let secrets: Arc = Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new( crate::secrets::SecretsCrypto::new(secrecy::SecretString::from( TEST_GATEWAY_CRYPTO_KEY.to_string(), )) .expect("crypto"), ))); let tool_registry = Arc::new(ToolRegistry::new()); let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new()); let ext_mgr = Arc::new(ExtensionManager::new( mcp_sm, Arc::new(crate::tools::mcp::process::McpProcessManager::new()), secrets.clone(), tool_registry, None, None, std::path::PathBuf::from("/tmp/wasm_tools"), std::path::PathBuf::from("/tmp/wasm_channels"), None, "test".to_string(), None, vec![], )); // Insert an expired flow (created 10 minutes ago) let flow = crate::cli::oauth_defaults::PendingOAuthFlow { extension_name: "test_tool".to_string(), display_name: "Test Tool".to_string(), token_url: "https://example.com/token".to_string(), client_id: "client123".to_string(), client_secret: None, redirect_uri: "https://example.com/oauth/callback".to_string(), code_verifier: None, access_token_field: "access_token".to_string(), secret_name: "test_token".to_string(), provider: None, validation_endpoint: None, scopes: vec![], user_id: "test".to_string(), secrets, sse_sender: None, gateway_token: None, created_at: std::time::Instant::now() .checked_sub(std::time::Duration::from_secs(600)) .expect("System uptime is too low to run expired flow test"), }; ext_mgr .pending_oauth_flows() .write() .await .insert("expired_state".to_string(), flow); let state = test_gateway_state(Some(ext_mgr)); let app = test_oauth_router(state); let req = axum::http::Request::builder() .uri("/oauth/callback?code=test_code&state=expired_state") .body(Body::empty()) .expect("request"); let resp = ServiceExt::>::oneshot(app, req) .await .expect("response"); assert_eq!(resp.status(), StatusCode::OK); let body = axum::body::to_bytes(resp.into_body(), 1024 * 64) .await .expect("body"); let html = String::from_utf8_lossy(&body); // Expired flow → error landing page assert!(html.contains("Authorization Failed")); } #[tokio::test] async fn test_oauth_callback_no_extension_manager() { use axum::body::Body; use tower::ServiceExt; // No extension manager set → graceful error let state = test_gateway_state(None); let app = test_oauth_router(state); let req = axum::http::Request::builder() .uri("/oauth/callback?code=test_code&state=some_state") .body(Body::empty()) .expect("request"); let resp = ServiceExt::>::oneshot(app, req) .await .expect("response"); assert_eq!(resp.status(), StatusCode::OK); let body = axum::body::to_bytes(resp.into_body(), 1024 * 64) .await .expect("body"); let html = String::from_utf8_lossy(&body); assert!(html.contains("Authorization Failed")); } #[tokio::test] async fn test_oauth_callback_strips_instance_prefix() { use axum::body::Body; use tower::ServiceExt; let secrets: Arc = Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new( crate::secrets::SecretsCrypto::new(secrecy::SecretString::from( TEST_GATEWAY_CRYPTO_KEY.to_string(), )) .expect("crypto"), ))); let tool_registry = Arc::new(ToolRegistry::new()); let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new()); let ext_mgr = Arc::new(ExtensionManager::new( mcp_sm, Arc::new(crate::tools::mcp::process::McpProcessManager::new()), secrets.clone(), tool_registry, None, None, std::path::PathBuf::from("/tmp/wasm_tools"), std::path::PathBuf::from("/tmp/wasm_channels"), None, "test".to_string(), None, vec![], )); // Insert a flow keyed by raw nonce "test_nonce" (without instance prefix). // Use an expired flow so the handler exits before attempting a real HTTP // token exchange — we only need to verify that the instance prefix was // stripped and the flow was found by the raw nonce. let flow = crate::cli::oauth_defaults::PendingOAuthFlow { extension_name: "test_tool".to_string(), display_name: "Test Tool".to_string(), token_url: "https://example.com/token".to_string(), client_id: "client123".to_string(), client_secret: None, redirect_uri: "https://example.com/oauth/callback".to_string(), code_verifier: None, access_token_field: "access_token".to_string(), secret_name: "test_token".to_string(), provider: None, validation_endpoint: None, scopes: vec![], user_id: "test".to_string(), secrets, sse_sender: None, gateway_token: None, // Expired — handler will reject after lookup (no network I/O) created_at: std::time::Instant::now() .checked_sub(std::time::Duration::from_secs(600)) .expect("System uptime is too low to run expired flow test"), }; ext_mgr .pending_oauth_flows() .write() .await .insert("test_nonce".to_string(), flow); let state = test_gateway_state(Some(ext_mgr.clone())); let app = test_oauth_router(state); // Send callback with instance prefix: "myinstance:test_nonce" // The handler should strip "myinstance:" and find the flow keyed by "test_nonce" let req = axum::http::Request::builder() .uri("/oauth/callback?code=fake_code&state=myinstance:test_nonce") .body(Body::empty()) .expect("request"); let resp = ServiceExt::>::oneshot(app, req) .await .expect("response"); assert_eq!(resp.status(), StatusCode::OK); let body = axum::body::to_bytes(resp.into_body(), 1024 * 64) .await .expect("body"); let html = String::from_utf8_lossy(&body); // The flow was found (stripped prefix matched) but is expired, so the // handler returns an error landing page. The flow being consumed from // the registry (checked below) proves the prefix was stripped correctly. assert!( html.contains("Authorization Failed"), "Expected error page, html was: {}", &html[..html.len().min(500)] ); // Verify the flow was consumed (removed from registry) assert!( ext_mgr .pending_oauth_flows() .read() .await .get("test_nonce") .is_none() ); } // --- Slack relay OAuth CSRF tests --- fn test_relay_oauth_router(state: Arc) -> Router { Router::new() .route( "/oauth/slack/callback", get(slack_relay_oauth_callback_handler), ) .with_state(state) } fn test_secrets_store() -> Arc { Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new( crate::secrets::SecretsCrypto::new(secrecy::SecretString::from( "test-key-at-least-32-chars-long!!".to_string(), )) .expect("crypto"), ))) } fn test_ext_mgr( secrets: Arc, ) -> Arc { let tool_registry = Arc::new(ToolRegistry::new()); let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new()); let mcp_pm = Arc::new(crate::tools::mcp::process::McpProcessManager::new()); Arc::new(ExtensionManager::new( mcp_sm, mcp_pm, secrets, tool_registry, None, None, std::path::PathBuf::from("/tmp/wasm_tools"), std::path::PathBuf::from("/tmp/wasm_channels"), None, "test".to_string(), None, vec![], )) } #[tokio::test] async fn test_relay_oauth_callback_missing_state_param() { use axum::body::Body; use tower::ServiceExt; let secrets = test_secrets_store(); let ext_mgr = test_ext_mgr(secrets); let state = test_gateway_state(Some(ext_mgr)); let app = test_relay_oauth_router(state); // Callback without state param should be rejected let req = axum::http::Request::builder() .uri("/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack") .body(Body::empty()) .expect("request"); let resp = ServiceExt::>::oneshot(app, req) .await .expect("response"); let body = axum::body::to_bytes(resp.into_body(), 1024 * 64) .await .expect("body"); let html = String::from_utf8_lossy(&body); assert!( html.contains("Invalid or expired authorization"), "Expected CSRF error, got: {}", &html[..html.len().min(300)] ); } #[tokio::test] async fn test_relay_oauth_callback_wrong_state_param() { use axum::body::Body; use tower::ServiceExt; let secrets = test_secrets_store(); // Store a valid nonce secrets .create( "test", crate::secrets::CreateSecretParams::new( format!("relay:{}:oauth_state", DEFAULT_RELAY_NAME), "correct-nonce-value", ), ) .await .expect("store nonce"); let ext_mgr = test_ext_mgr(secrets); let state = test_gateway_state(Some(ext_mgr)); let app = test_relay_oauth_router(state); // Callback with wrong state param let req = axum::http::Request::builder() .uri("/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack&state=wrong-nonce") .body(Body::empty()) .expect("request"); let resp = ServiceExt::>::oneshot(app, req) .await .expect("response"); let body = axum::body::to_bytes(resp.into_body(), 1024 * 64) .await .expect("body"); let html = String::from_utf8_lossy(&body); assert!( html.contains("Invalid or expired authorization"), "Expected CSRF error for wrong nonce, got: {}", &html[..html.len().min(300)] ); } #[tokio::test] async fn test_relay_oauth_callback_correct_state_proceeds() { use axum::body::Body; use tower::ServiceExt; let secrets = test_secrets_store(); let nonce = "valid-test-nonce-12345"; // Store the correct nonce secrets .create( "test", crate::secrets::CreateSecretParams::new( format!("relay:{}:oauth_state", DEFAULT_RELAY_NAME), nonce, ), ) .await .expect("store nonce"); let ext_mgr = test_ext_mgr(secrets.clone()); let state = test_gateway_state(Some(ext_mgr)); let app = test_relay_oauth_router(state); // Callback with correct state param — will pass CSRF check // but may fail downstream (no real relay service) — that's OK, // we just verify it doesn't return a CSRF error. let req = axum::http::Request::builder() .uri(format!( "/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack&state={}", nonce )) .body(Body::empty()) .expect("request"); let resp = ServiceExt::>::oneshot(app, req) .await .expect("response"); let body = axum::body::to_bytes(resp.into_body(), 1024 * 64) .await .expect("body"); let html = String::from_utf8_lossy(&body); // Should NOT contain the CSRF error message assert!( !html.contains("Invalid or expired authorization"), "Should have passed CSRF check, got: {}", &html[..html.len().min(300)] ); // Verify the nonce was consumed (deleted) let state_key = format!("relay:{}:oauth_state", DEFAULT_RELAY_NAME); let exists = secrets.exists("test", &state_key).await.unwrap_or(true); assert!(!exists, "CSRF nonce should be deleted after use"); } }