//! 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; #[cfg(test)] mod tests; 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::auth::{CombinedAuthState, DbAuthenticator, MultiAuthState}; use self::server::GatewayState; use self::sse::SseManager; use self::types::AppEvent; /// Web gateway channel implementing the Channel trait. pub struct GatewayChannel { config: GatewayConfig, state: Arc, /// Combined auth state: env-var tokens + optional DB-backed tokens. auth: CombinedAuthState, } impl GatewayChannel { /// Create a new gateway channel. /// /// If no auth token is configured, generates a random one and prints it. /// Builds a single-user `MultiAuthState` from the config. 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 auth = CombinedAuthState { env_auth: MultiAuthState::single(auth_token, config.user_id.clone()), db_auth: None, }; let state = Arc::new(GatewayState { msg_tx: tokio::sync::RwLock::new(None), sse: Arc::new(SseManager::new()), workspace: None, workspace_pool: 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, owner_id: config.user_id.clone(), default_sender_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::PerUserRateLimiter::new(30, 60), oauth_rate_limiter: server::RateLimiter::new(10, 60), webhook_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(), active_config: server::ActiveConfigSnapshot::default(), }); Self { config, state, auth, } } /// Rebind the single-user auth identity to the durable owner scope while /// preserving the configured gateway sender/routing identity. pub fn with_owner_scope(mut self, owner_id: impl Into) -> Self { let owner_id = owner_id.into(); let single_user_token = self.auth.env_auth.first_token().map(ToOwned::to_owned); if let Some(token) = single_user_token { self.auth.env_auth = MultiAuthState::single(token, owner_id.clone()); } self.rebuild_state(|s| s.owner_id = owner_id); self } /// 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: Arc::new(SseManager::from_sender(self.state.sse.sender())), workspace: self.state.workspace.clone(), workspace_pool: self.state.workspace_pool.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(), owner_id: self.state.owner_id.clone(), default_sender_id: self.state.default_sender_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::PerUserRateLimiter::new(30, 60), oauth_rate_limiter: server::RateLimiter::new(10, 60), webhook_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, active_config: self.state.active_config.clone(), }; 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 } /// Enable DB-backed token authentication alongside env-var tokens. pub fn with_db_auth(mut self, store: Arc) -> Self { self.auth.db_auth = Some(DbAuthenticator::new(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 } /// Inject the active (resolved) configuration snapshot for the status endpoint. pub fn with_active_config(mut self, config: server::ActiveConfigSnapshot) -> Self { self.rebuild_state(|s| s.active_config = config); self } /// Inject the per-user workspace pool for multi-user mode. pub fn with_workspace_pool(mut self, pool: Arc) -> Self { self.rebuild_state(|s| s.workspace_pool = Some(pool)); self } /// Get the first auth token (for printing to console on startup). pub fn auth_token(&self) -> &str { self.auth.env_auth.first_token().unwrap_or("") } /// 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.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_for_user( &msg.user_id, AppEvent::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) => AppEvent::Thinking { message: msg, thread_id: thread_id.clone(), }, StatusUpdate::ToolStarted { name } => AppEvent::ToolStarted { name, thread_id: thread_id.clone(), }, StatusUpdate::ToolCompleted { name, success, error, parameters, } => AppEvent::ToolCompleted { name, success, error, parameters, thread_id: thread_id.clone(), }, StatusUpdate::ToolResult { name, preview } => AppEvent::ToolResult { name, preview, thread_id: thread_id.clone(), }, StatusUpdate::StreamChunk(content) => AppEvent::StreamChunk { content, thread_id: thread_id.clone(), }, StatusUpdate::Status(msg) => AppEvent::Status { message: msg, thread_id: thread_id.clone(), }, StatusUpdate::JobStarted { job_id, title, browse_url, } => AppEvent::JobStarted { job_id, title, browse_url, }, StatusUpdate::ApprovalNeeded { request_id, tool_name, description, parameters, allow_always, } => AppEvent::ApprovalNeeded { request_id, tool_name, description, parameters: serde_json::to_string_pretty(¶meters) .unwrap_or_else(|_| parameters.to_string()), thread_id, allow_always, }, StatusUpdate::AuthRequired { extension_name, instructions, auth_url, setup_url, } => AppEvent::AuthRequired { extension_name, instructions, auth_url, setup_url, }, StatusUpdate::AuthCompleted { extension_name, success, message, } => AppEvent::AuthCompleted { extension_name, success, message, }, StatusUpdate::ImageGenerated { data_url, path } => AppEvent::ImageGenerated { data_url, path, thread_id: thread_id.clone(), }, StatusUpdate::Suggestions { suggestions } => AppEvent::Suggestions { suggestions, thread_id: thread_id.clone(), }, StatusUpdate::ReasoningUpdate { narrative, decisions, } => AppEvent::ReasoningUpdate { narrative, decisions: decisions .into_iter() .map(|d| crate::channels::web::types::ToolDecisionDto { tool_name: d.tool_name, rationale: d.rationale, }) .collect(), thread_id, }, StatusUpdate::TurnCost { input_tokens, output_tokens, cost_usd, } => AppEvent::TurnCost { input_tokens, output_tokens, cost_usd, thread_id, }, }; // Scope events to the user when user_id is available in metadata. // When user_id is missing (heartbeat, routines), events go to all // subscribers. In multi-tenant mode this leaks status across users. if let Some(uid) = metadata.get("user_id").and_then(|v| v.as_str()) { self.state.sse.broadcast_for_user(uid, event); } else { tracing::debug!("Status event missing user_id in metadata; broadcasting globally"); 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_for_user( user_id, AppEvent::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(()) } }