//! Web gateway channel for browser-based access to IronClaw. //! //! Provides a single-page web UI with: //! - Chat with the agent (via REST + SSE) //! - Workspace/memory browsing //! - Job management //! //! ```text //! Browser ─── POST /api/chat/send ──► Agent Loop //! ◄── GET /api/chat/events ── SSE stream //! ─── GET /api/chat/ws ─────► WebSocket (bidirectional) //! ─── GET /api/memory/* ────► Workspace //! ─── GET /api/jobs/* ──────► Database //! ◄── GET / ───────────────── Static HTML/CSS/JS //! ``` pub mod auth; pub(crate) mod handlers; pub mod log_layer; pub mod openai_compat; pub mod server; pub mod sse; pub mod types; pub(crate) mod util; pub mod ws; /// Test helpers for gateway integration tests. /// /// Always compiled (not behind `#[cfg(test)]`) so that integration tests in /// `tests/` -- which import this crate as a regular dependency -- can use /// [`TestGatewayBuilder`](test_helpers::TestGatewayBuilder). pub mod test_helpers; use std::net::SocketAddr; use std::sync::Arc; use async_trait::async_trait; use tokio::sync::mpsc; use tokio_stream::wrappers::ReceiverStream; use crate::agent::SessionManager; use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; use crate::config::GatewayConfig; use crate::db::Database; use crate::error::ChannelError; use crate::extensions::ExtensionManager; use crate::orchestrator::job_manager::ContainerJobManager; use crate::skills::catalog::SkillCatalog; use crate::skills::registry::SkillRegistry; use crate::tools::ToolRegistry; use crate::workspace::Workspace; use self::log_layer::{LogBroadcaster, LogLevelHandle}; use self::server::GatewayState; use self::sse::SseManager; use self::types::SseEvent; /// Web gateway channel implementing the Channel trait. pub struct GatewayChannel { config: GatewayConfig, state: Arc, /// The actual auth token in use (generated or from config). auth_token: String, } impl GatewayChannel { /// Create a new gateway channel. /// /// If no auth token is configured, generates a random one and prints it. pub fn new(config: GatewayConfig) -> Self { let auth_token = config.auth_token.clone().unwrap_or_else(|| { use rand::RngCore; use rand::rngs::OsRng; let mut bytes = [0u8; 32]; OsRng.fill_bytes(&mut bytes); bytes.iter().map(|b| format!("{b:02x}")).collect() }); let state = 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: None, tool_registry: None, store: None, job_manager: None, prompt_queue: None, scheduler: None, user_id: config.user_id.clone(), shutdown_tx: tokio::sync::RwLock::new(None), ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())), llm_provider: None, skill_registry: None, skill_catalog: None, chat_rate_limiter: server::RateLimiter::new(30, 60), oauth_rate_limiter: server::RateLimiter::new(10, 60), registry_entries: Vec::new(), cost_guard: None, routine_engine: Arc::new(tokio::sync::RwLock::new(None)), startup_time: std::time::Instant::now(), }); Self { config, state, auth_token, } } /// Helper to rebuild state, copying existing fields and applying a mutation. fn rebuild_state(&mut self, mutate: impl FnOnce(&mut GatewayState)) { let mut new_state = GatewayState { msg_tx: tokio::sync::RwLock::new(None), // Preserve the existing broadcast channel so sender handles remain valid. sse: SseManager::from_sender(self.state.sse.sender()), workspace: self.state.workspace.clone(), session_manager: self.state.session_manager.clone(), log_broadcaster: self.state.log_broadcaster.clone(), log_level_handle: self.state.log_level_handle.clone(), extension_manager: self.state.extension_manager.clone(), tool_registry: self.state.tool_registry.clone(), store: self.state.store.clone(), job_manager: self.state.job_manager.clone(), prompt_queue: self.state.prompt_queue.clone(), scheduler: self.state.scheduler.clone(), user_id: self.state.user_id.clone(), shutdown_tx: tokio::sync::RwLock::new(None), ws_tracker: self.state.ws_tracker.clone(), llm_provider: self.state.llm_provider.clone(), skill_registry: self.state.skill_registry.clone(), skill_catalog: self.state.skill_catalog.clone(), chat_rate_limiter: server::RateLimiter::new(30, 60), oauth_rate_limiter: server::RateLimiter::new(10, 60), registry_entries: self.state.registry_entries.clone(), cost_guard: self.state.cost_guard.clone(), routine_engine: Arc::clone(&self.state.routine_engine), startup_time: self.state.startup_time, }; mutate(&mut new_state); self.state = Arc::new(new_state); } /// Inject the workspace reference for the memory API. pub fn with_workspace(mut self, workspace: Arc) -> Self { self.rebuild_state(|s| s.workspace = Some(workspace)); self } /// Inject the session manager for thread/session info. pub fn with_session_manager(mut self, sm: Arc) -> Self { self.rebuild_state(|s| s.session_manager = Some(sm)); self } /// Inject the log broadcaster for the logs SSE endpoint. pub fn with_log_broadcaster(mut self, lb: Arc) -> Self { self.rebuild_state(|s| s.log_broadcaster = Some(lb)); self } /// Inject the log level handle for runtime log level control. pub fn with_log_level_handle(mut self, h: Arc) -> Self { self.rebuild_state(|s| s.log_level_handle = Some(h)); self } /// Inject the extension manager for the extensions API. pub fn with_extension_manager(mut self, em: Arc) -> Self { self.rebuild_state(|s| s.extension_manager = Some(em)); self } /// Inject the tool registry for the extensions API. pub fn with_tool_registry(mut self, tr: Arc) -> Self { self.rebuild_state(|s| s.tool_registry = Some(tr)); self } /// Inject the database store for sandbox job persistence. pub fn with_store(mut self, store: Arc) -> Self { self.rebuild_state(|s| s.store = Some(store)); self } /// Inject the container job manager for sandbox operations. pub fn with_job_manager(mut self, jm: Arc) -> Self { self.rebuild_state(|s| s.job_manager = Some(jm)); self } /// Inject the prompt queue for Claude Code follow-up prompts. pub fn with_prompt_queue( mut self, pq: Arc< tokio::sync::Mutex< std::collections::HashMap< uuid::Uuid, std::collections::VecDeque, >, >, >, ) -> Self { self.rebuild_state(|s| s.prompt_queue = Some(pq)); self } /// Inject the scheduler for sending follow-up messages to agent jobs. pub fn with_scheduler(mut self, slot: crate::tools::builtin::SchedulerSlot) -> Self { self.rebuild_state(|s| s.scheduler = Some(slot)); self } /// Inject the skill registry for skill management API. pub fn with_skill_registry(mut self, sr: Arc>) -> Self { self.rebuild_state(|s| s.skill_registry = Some(sr)); self } /// Inject the skill catalog for skill search API. pub fn with_skill_catalog(mut self, sc: Arc) -> Self { self.rebuild_state(|s| s.skill_catalog = Some(sc)); self } /// Inject the LLM provider for OpenAI-compatible API proxy. pub fn with_llm_provider(mut self, llm: Arc) -> Self { self.rebuild_state(|s| s.llm_provider = Some(llm)); self } /// Inject registry catalog entries for the available extensions API. pub fn with_registry_entries(mut self, entries: Vec) -> Self { self.rebuild_state(|s| s.registry_entries = entries); self } /// Inject the cost guard for token/cost tracking in the status popover. pub fn with_cost_guard(mut self, cg: Arc) -> Self { self.rebuild_state(|s| s.cost_guard = Some(cg)); self } /// Inject a shared routine engine slot used by other HTTP ingress paths. pub fn with_routine_engine_slot(mut self, slot: server::RoutineEngineSlot) -> Self { self.rebuild_state(|s| s.routine_engine = slot); self } /// Get the auth token (for printing to console on startup). pub fn auth_token(&self) -> &str { &self.auth_token } /// Get a reference to the shared gateway state (for the agent to push SSE events). pub fn state(&self) -> &Arc { &self.state } } #[async_trait] impl Channel for GatewayChannel { fn name(&self) -> &str { "gateway" } async fn start(&self) -> Result { let (tx, rx) = mpsc::channel(256); *self.state.msg_tx.write().await = Some(tx); let addr: SocketAddr = format!("{}:{}", self.config.host, self.config.port) .parse() .map_err(|e| ChannelError::StartupFailed { name: "gateway".to_string(), reason: format!( "Invalid address '{}:{}': {}", self.config.host, self.config.port, e ), })?; server::start_server(addr, self.state.clone(), self.auth_token.clone()).await?; Ok(Box::pin(ReceiverStream::new(rx))) } async fn respond( &self, msg: &IncomingMessage, response: OutgoingResponse, ) -> Result<(), ChannelError> { let thread_id = match &msg.thread_id { Some(tid) => tid.clone(), None => { tracing::warn!( "Gateway respond with no thread_id — skipping (clients would drop it)" ); return Ok(()); } }; self.state.sse.broadcast(SseEvent::Response { content: response.content, thread_id, }); Ok(()) } async fn send_status( &self, status: StatusUpdate, metadata: &serde_json::Value, ) -> Result<(), ChannelError> { let thread_id = metadata .get("thread_id") .and_then(|v| v.as_str()) .map(String::from); let event = match status { StatusUpdate::Thinking(msg) => SseEvent::Thinking { message: msg, thread_id: thread_id.clone(), }, StatusUpdate::ToolStarted { name } => SseEvent::ToolStarted { name, thread_id: thread_id.clone(), }, StatusUpdate::ToolCompleted { name, success, error, parameters, } => SseEvent::ToolCompleted { name, success, error, parameters, thread_id: thread_id.clone(), }, StatusUpdate::ToolResult { name, preview } => SseEvent::ToolResult { name, preview, thread_id: thread_id.clone(), }, StatusUpdate::StreamChunk(content) => SseEvent::StreamChunk { content, thread_id: thread_id.clone(), }, StatusUpdate::Status(msg) => SseEvent::Status { message: msg, thread_id: thread_id.clone(), }, StatusUpdate::JobStarted { job_id, title, browse_url, } => SseEvent::JobStarted { job_id, title, browse_url, }, StatusUpdate::ApprovalNeeded { request_id, tool_name, description, parameters, } => SseEvent::ApprovalNeeded { request_id, tool_name, description, parameters: serde_json::to_string_pretty(¶meters) .unwrap_or_else(|_| parameters.to_string()), thread_id, }, StatusUpdate::AuthRequired { extension_name, instructions, auth_url, setup_url, } => SseEvent::AuthRequired { extension_name, instructions, auth_url, setup_url, }, StatusUpdate::AuthCompleted { extension_name, success, message, } => SseEvent::AuthCompleted { extension_name, success, message, }, StatusUpdate::ImageGenerated { data_url, path } => SseEvent::ImageGenerated { data_url, path, thread_id: thread_id.clone(), }, StatusUpdate::Suggestions { suggestions } => SseEvent::Suggestions { suggestions, thread_id, }, }; self.state.sse.broadcast(event); Ok(()) } async fn broadcast( &self, _user_id: &str, response: OutgoingResponse, ) -> Result<(), ChannelError> { let thread_id = match response.thread_id { Some(tid) => tid, None => { tracing::warn!( "Gateway broadcast with no thread_id — skipping (clients would drop it)" ); return Ok(()); } }; self.state.sse.broadcast(SseEvent::Response { content: response.content, thread_id, }); Ok(()) } async fn health_check(&self) -> Result<(), ChannelError> { if self.state.msg_tx.read().await.is_some() { Ok(()) } else { Err(ChannelError::HealthCheckFailed { name: "gateway".to_string(), }) } } async fn shutdown(&self) -> Result<(), ChannelError> { if let Some(tx) = self.state.shutdown_tx.write().await.take() { let _ = tx.send(()); } *self.state.msg_tx.write().await = None; Ok(()) } }