diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 1780ba9d..4282daa5 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -146,6 +146,8 @@ pub struct AgentDeps { pub transcription: Option>, /// Document text extraction middleware for PDF, DOCX, PPTX, etc. pub document_extraction: Option>, + /// Sandbox readiness state for full-job routine dispatch. + pub sandbox_readiness: crate::agent::routine_engine::SandboxReadiness, /// Software builder for self-repair tool rebuilding. pub builder: Option>, } @@ -556,6 +558,7 @@ impl Agent { Some(self.scheduler.clone()), self.tools().clone(), self.safety().clone(), + self.deps.sandbox_readiness, )); // Register routine tools diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs index d3825b2f..0b47c928 100644 --- a/src/agent/dispatcher.rs +++ b/src/agent/dispatcher.rs @@ -1199,6 +1199,7 @@ mod tests { http_interceptor: None, transcription: None, document_extraction: None, + sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig, builder: None, }; @@ -2070,6 +2071,7 @@ mod tests { http_interceptor: None, transcription: None, document_extraction: None, + sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig, builder: None, }; @@ -2189,6 +2191,7 @@ mod tests { http_interceptor: None, transcription: None, document_extraction: None, + sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig, builder: None, }; diff --git a/src/agent/mod.rs b/src/agent/mod.rs index ee980233..81c56dad 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -39,7 +39,7 @@ pub use context_monitor::{CompactionStrategy, ContextBreakdown, ContextMonitor}; pub use heartbeat::{HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_heartbeat}; pub use router::{MessageIntent, Router}; pub use routine::{Routine, RoutineAction, RoutineRun, Trigger}; -pub use routine_engine::RoutineEngine; +pub use routine_engine::{RoutineEngine, SandboxReadiness}; pub use scheduler::Scheduler; pub use self_repair::{BrokenTool, RepairResult, RepairTask, SelfRepair, StuckJob}; pub use session::{PendingApproval, PendingAuth, Session, Thread, ThreadState, Turn, TurnState}; diff --git a/src/agent/routine_engine.rs b/src/agent/routine_engine.rs index 6e216fdc..a4f35ccb 100644 --- a/src/agent/routine_engine.rs +++ b/src/agent/routine_engine.rs @@ -44,6 +44,17 @@ enum EventMatcher { System { routine: Routine }, } +/// Distinguishes why sandbox is unavailable so error messages are accurate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SandboxReadiness { + /// Docker is available and sandbox is enabled. + Available, + /// User explicitly disabled sandboxing (SANDBOX_ENABLED=false). + DisabledByConfig, + /// Sandbox is enabled but Docker is not running or not installed. + DockerUnavailable, +} + /// The routine execution engine. pub struct RoutineEngine { config: RoutineConfig, @@ -62,6 +73,8 @@ pub struct RoutineEngine { tools: Arc, /// Safety layer for tool output sanitization. safety: Arc, + /// Sandbox readiness state for full-job dispatch. + sandbox_readiness: SandboxReadiness, /// Timestamp when this engine instance was created. Used by /// `sync_dispatched_runs` to distinguish orphaned runs (from a previous /// process) from actively-watched runs (from this process). @@ -79,6 +92,7 @@ impl RoutineEngine { scheduler: Option>, tools: Arc, safety: Arc, + sandbox_readiness: SandboxReadiness, ) -> Self { Self { config, @@ -91,6 +105,7 @@ impl RoutineEngine { scheduler, tools, safety, + sandbox_readiness, boot_time: Utc::now(), } } @@ -689,6 +704,7 @@ impl RoutineEngine { scheduler: self.scheduler.clone(), tools: self.tools.clone(), safety: self.safety.clone(), + sandbox_readiness: self.sandbox_readiness, }; tokio::spawn(async move { @@ -724,6 +740,7 @@ impl RoutineEngine { scheduler: self.scheduler.clone(), tools: self.tools.clone(), safety: self.safety.clone(), + sandbox_readiness: self.sandbox_readiness, }; // Record the run in DB, then spawn execution @@ -860,6 +877,7 @@ struct EngineContext { scheduler: Option>, tools: Arc, safety: Arc, + sandbox_readiness: SandboxReadiness, } /// Execute a routine run. Handles both lightweight and full_job modes. @@ -1040,6 +1058,24 @@ async fn execute_full_job( run: &RoutineRun, execution: &FullJobExecutionConfig<'_>, ) -> Result<(RunStatus, Option, Option), RoutineError> { + match ctx.sandbox_readiness { + SandboxReadiness::Available => {} + SandboxReadiness::DisabledByConfig => { + return Err(RoutineError::JobDispatchFailed { + reason: "Sandboxing is disabled (SANDBOX_ENABLED=false). \ + Full-job routines require sandbox." + .to_string(), + }); + } + SandboxReadiness::DockerUnavailable => { + return Err(RoutineError::JobDispatchFailed { + reason: "Sandbox is enabled but Docker is not available. \ + Install Docker or set SANDBOX_ENABLED=false." + .to_string(), + }); + } + } + let scheduler = ctx .scheduler .as_ref() @@ -1710,6 +1746,7 @@ pub fn spawn_cron_ticker( // never races with FullJobWatcher instances from this process. engine.sync_dispatched_runs().await; engine.check_cron_triggers().await; + engine.sync_dispatched_runs().await; } }) } @@ -1723,6 +1760,56 @@ fn truncate(s: &str, max: usize) -> String { } } +/// Sanitize a summary string from job transitions before using in notifications. +/// +/// `last_reason` comes from untrusted container code, so we: +/// 1. Strip control characters (except newline) to prevent terminal injection +/// 2. Strip HTML tags to prevent injection in web-rendered notifications +/// 3. Collapse multiple whitespace/newlines to single spaces for cleaner output +/// 4. Truncate to 500 chars to prevent oversized notifications +#[cfg(test)] +fn sanitize_summary(s: &str) -> String { + // Strip control characters (keep newline for now, collapse later) + let no_control: String = s + .chars() + .filter(|c| !c.is_control() || *c == '\n') + .collect(); + + // Strip HTML tags (e.g. world"), + "Hello alert('xss') world" + ); + assert_eq!( + sanitize_summary("bold and link"), + "bold and link" + ); + assert_eq!(sanitize_summary(""), ""); + } + + #[test] + fn test_sanitize_summary_multibyte_truncation() { + use super::sanitize_summary; + + // Ensure truncation doesn't panic on multi-byte chars near the boundary + let s = "a".repeat(498) + "\u{1F600}\u{1F600}"; // 498 + two 4-byte emoji + let result = sanitize_summary(&s); + assert!(result.len() <= 503); + assert!(result.ends_with("...")); + } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 49287308..f1e8c276 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -525,6 +525,7 @@ pub trait RoutineStore: Send + Sync { run_id: Uuid, job_id: Uuid, ) -> Result<(), DatabaseError>; + /// List routine runs that were dispatched as full_job but have not yet /// been finalized (status='running' with a linked job_id). async fn list_dispatched_routine_runs(&self) -> Result, DatabaseError>; diff --git a/src/main.rs b/src/main.rs index e7477bc3..9c482e1b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -272,6 +272,21 @@ async fn async_main() -> anyhow::Result<()> { let prompt_queue = orch.prompt_queue; let docker_status = orch.docker_status; + // Derive user-facing warning from docker_status for channel notification + let docker_user_warning: Option = match docker_status { + ironclaw::sandbox::DockerStatus::NotInstalled => Some( + "Sandbox is enabled but Docker is not installed -- \ + full_job routines will fail until Docker is available." + .to_string(), + ), + ironclaw::sandbox::DockerStatus::NotRunning => Some( + "Sandbox is enabled but Docker is not running -- \ + full_job routines will fail until Docker is started." + .to_string(), + ), + _ => None, + }; + // ── Channel setup ────────────────────────────────────────────────── let channels = ChannelManager::new(); @@ -748,9 +763,17 @@ async fn async_main() -> anyhow::Result<()> { document_extraction: Some(Arc::new( ironclaw::document_extraction::DocumentExtractionMiddleware::new(), )), + sandbox_readiness: if !config.sandbox.enabled { + ironclaw::agent::routine_engine::SandboxReadiness::DisabledByConfig + } else if docker_status.is_ok() { + ironclaw::agent::routine_engine::SandboxReadiness::Available + } else { + ironclaw::agent::routine_engine::SandboxReadiness::DockerUnavailable + }, builder: components.builder, }; + let channels_for_warnings = Arc::clone(&channels); let mut agent = Agent::new( config.agent.clone(), deps, @@ -957,6 +980,27 @@ async fn async_main() -> anyhow::Result<()> { }); } + // Notify user if sandbox is unavailable (Docker missing/not running) + if let Some(warning) = docker_user_warning { + let channels_ref = Arc::clone(&channels_for_warnings); + tokio::spawn(async move { + // Delay to let channels finish connecting before sending the warning. + // 5s is generous but avoids the message being lost on slow startups. + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + tracing::debug!("Sending sandbox-unavailable warning to connected channels"); + let response = ironclaw::channels::OutgoingResponse { + content: format!("Warning: {warning}"), + thread_id: None, + attachments: Vec::new(), + metadata: serde_json::json!({ + "source": "system", + "type": "warning", + }), + }; + let _ = channels_ref.broadcast_all("default", response).await; + }); + } + agent.run().await?; // ── Shutdown ──────────────────────────────────────────────────────── diff --git a/src/testing/mod.rs b/src/testing/mod.rs index d5504393..953cbfcd 100644 --- a/src/testing/mod.rs +++ b/src/testing/mod.rs @@ -492,6 +492,7 @@ impl TestHarnessBuilder { http_interceptor: None, transcription: None, document_extraction: None, + sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig, builder: None, }; diff --git a/tests/e2e_routine_heartbeat.rs b/tests/e2e_routine_heartbeat.rs index 116dd1e0..b467c9c8 100644 --- a/tests/e2e_routine_heartbeat.rs +++ b/tests/e2e_routine_heartbeat.rs @@ -20,7 +20,7 @@ mod tests { RunStatus, Trigger, }; use ironclaw::agent::routine_engine::RoutineEngine; - use ironclaw::agent::{HeartbeatConfig, HeartbeatRunner, Scheduler}; + use ironclaw::agent::{HeartbeatConfig, HeartbeatRunner, SandboxReadiness, Scheduler}; use ironclaw::channels::IncomingMessage; use ironclaw::config::{AgentConfig, RoutineConfig, SafetyConfig}; use ironclaw::context::{ContextManager, JobContext}; @@ -266,6 +266,7 @@ mod tests { Some(scheduler), registry, safety, + SandboxReadiness::DisabledByConfig, )) } @@ -346,6 +347,7 @@ mod tests { None, tools, safety, + SandboxReadiness::DisabledByConfig, )); // Insert a cron routine with next_fire_at in the past. @@ -423,6 +425,7 @@ mod tests { None, tools, safety, + SandboxReadiness::DisabledByConfig, )); // Insert an event routine matching "deploy.*production". @@ -516,6 +519,7 @@ mod tests { None, tools, safety, + SandboxReadiness::DisabledByConfig, )); let routine = make_routine( @@ -623,6 +627,7 @@ mod tests { None, tools, safety, + SandboxReadiness::DisabledByConfig, )); let mut filters = std::collections::HashMap::new(); @@ -764,6 +769,7 @@ mod tests { None, tools, safety, + SandboxReadiness::DisabledByConfig, )); // Insert an event routine with 1-hour cooldown. @@ -949,6 +955,7 @@ mod tests { None, tools, safety, + SandboxReadiness::DisabledByConfig, )); (engine, db, dir) @@ -1078,6 +1085,7 @@ mod tests { None, // no scheduler — rejected before dispatch tools, safety, + SandboxReadiness::DisabledByConfig, )); // Create a full_job routine with max_concurrent = 1 @@ -1186,6 +1194,7 @@ mod tests { None, tools, safety, + SandboxReadiness::DisabledByConfig, )); // Insert a due cron routine diff --git a/tests/e2e_telegram_message_routing.rs b/tests/e2e_telegram_message_routing.rs index a96aabe4..fe9a9b04 100644 --- a/tests/e2e_telegram_message_routing.rs +++ b/tests/e2e_telegram_message_routing.rs @@ -198,6 +198,7 @@ mod tests { http_interceptor: None, transcription: None, document_extraction: None, + sandbox_readiness: ironclaw::agent::SandboxReadiness::DisabledByConfig, builder: None, }; diff --git a/tests/support/gateway_workflow_harness.rs b/tests/support/gateway_workflow_harness.rs index c2db4427..f5f01266 100644 --- a/tests/support/gateway_workflow_harness.rs +++ b/tests/support/gateway_workflow_harness.rs @@ -257,6 +257,7 @@ impl GatewayWorkflowHarness { http_interceptor: None, transcription: None, document_extraction: None, + sandbox_readiness: ironclaw::agent::SandboxReadiness::DisabledByConfig, builder: None, }, channels, diff --git a/tests/support/test_rig.rs b/tests/support/test_rig.rs index e6c4a6e2..d078dc77 100644 --- a/tests/support/test_rig.rs +++ b/tests/support/test_rig.rs @@ -578,6 +578,7 @@ impl TestRigBuilder { None, components.tools.clone(), components.safety.clone(), + ironclaw::agent::SandboxReadiness::Available, // tests don't use real Docker )); components .tools @@ -642,6 +643,7 @@ impl TestRigBuilder { }, transcription: None, document_extraction: None, + sandbox_readiness: ironclaw::agent::SandboxReadiness::Available, // tests don't use real Docker builder: None, };