use super::traits::{Tool, ToolResult}; use crate::config::Config; use crate::cron::{self, DeliveryConfig, JobType, Schedule, SessionTarget}; use crate::security::SecurityPolicy; use async_trait::async_trait; use serde_json::json; use std::sync::Arc; pub struct CronAddTool { config: Arc, security: Arc, } impl CronAddTool { pub fn new(config: Arc, security: Arc) -> Self { Self { config, security } } } #[async_trait] impl Tool for CronAddTool { fn name(&self) -> &str { "cron_add" } fn description(&self) -> &str { "Create a scheduled cron job (shell or agent) with cron/at/every schedules" } fn parameters_schema(&self) -> serde_json::Value { json!({ "type": "object", "properties": { "name": { "type": "string" }, "schedule": { "type": "object", "description": "Schedule object: {kind:'cron',expr,tz?} | {kind:'at',at} | {kind:'every',every_ms}" }, "job_type": { "type": "string", "enum": ["shell", "agent"] }, "command": { "type": "string" }, "prompt": { "type": "string" }, "session_target": { "type": "string", "enum": ["isolated", "main"] }, "model": { "type": "string" }, "delivery": { "type": "object" }, "delete_after_run": { "type": "boolean" } }, "required": ["schedule"] }) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { if !self.config.cron.enabled { return Ok(ToolResult { success: false, output: String::new(), error: Some("cron is disabled by config (cron.enabled=false)".to_string()), }); } let schedule = match args.get("schedule") { Some(v) => match serde_json::from_value::(v.clone()) { Ok(schedule) => schedule, Err(e) => { return Ok(ToolResult { success: false, output: String::new(), error: Some(format!("Invalid schedule: {e}")), }); } }, None => { return Ok(ToolResult { success: false, output: String::new(), error: Some("Missing 'schedule' parameter".to_string()), }); } }; let name = args .get("name") .and_then(serde_json::Value::as_str) .map(str::to_string); let job_type = match args.get("job_type").and_then(serde_json::Value::as_str) { Some("agent") => JobType::Agent, Some("shell") => JobType::Shell, Some(other) => { return Ok(ToolResult { success: false, output: String::new(), error: Some(format!("Invalid job_type: {other}")), }); } None => { if args.get("prompt").is_some() { JobType::Agent } else { JobType::Shell } } }; let default_delete_after_run = matches!(schedule, Schedule::At { .. }); let delete_after_run = args .get("delete_after_run") .and_then(serde_json::Value::as_bool) .unwrap_or(default_delete_after_run); let result = match job_type { JobType::Shell => { let command = match args.get("command").and_then(serde_json::Value::as_str) { Some(command) if !command.trim().is_empty() => command, _ => { return Ok(ToolResult { success: false, output: String::new(), error: Some("Missing 'command' for shell job".to_string()), }); } }; if !self.security.is_command_allowed(command) { return Ok(ToolResult { success: false, output: String::new(), error: Some(format!("Command blocked by security policy: {command}")), }); } cron::add_shell_job(&self.config, name, schedule, command) } JobType::Agent => { let prompt = match args.get("prompt").and_then(serde_json::Value::as_str) { Some(prompt) if !prompt.trim().is_empty() => prompt, _ => { return Ok(ToolResult { success: false, output: String::new(), error: Some("Missing 'prompt' for agent job".to_string()), }); } }; let session_target = match args.get("session_target") { Some(v) => match serde_json::from_value::(v.clone()) { Ok(target) => target, Err(e) => { return Ok(ToolResult { success: false, output: String::new(), error: Some(format!("Invalid session_target: {e}")), }); } }, None => SessionTarget::Isolated, }; let model = args .get("model") .and_then(serde_json::Value::as_str) .map(str::to_string); let delivery = match args.get("delivery") { Some(v) => match serde_json::from_value::(v.clone()) { Ok(cfg) => Some(cfg), Err(e) => { return Ok(ToolResult { success: false, output: String::new(), error: Some(format!("Invalid delivery config: {e}")), }); } }, None => None, }; cron::add_agent_job( &self.config, name, schedule, prompt, session_target, model, delivery, delete_after_run, ) } }; match result { Ok(job) => Ok(ToolResult { success: true, output: serde_json::to_string_pretty(&json!({ "id": job.id, "name": job.name, "job_type": job.job_type, "schedule": job.schedule, "next_run": job.next_run, "enabled": job.enabled }))?, error: None, }), Err(e) => Ok(ToolResult { success: false, output: String::new(), error: Some(e.to_string()), }), } } } #[cfg(test)] mod tests { use super::*; use crate::config::Config; use crate::security::AutonomyLevel; use tempfile::TempDir; async fn test_config(tmp: &TempDir) -> Arc { let config = Config { workspace_dir: tmp.path().join("workspace"), config_path: tmp.path().join("config.toml"), ..Config::default() }; tokio::fs::create_dir_all(&config.workspace_dir).await.unwrap(); Arc::new(config) } fn test_security(cfg: &Config) -> Arc { Arc::new(SecurityPolicy::from_config( &cfg.autonomy, &cfg.workspace_dir, )) } #[tokio::test] async fn adds_shell_job() { let tmp = TempDir::new().unwrap(); let cfg = test_config(&tmp).await; let tool = CronAddTool::new(cfg.clone(), test_security(&cfg)); let result = tool .execute(json!({ "schedule": { "kind": "cron", "expr": "*/5 * * * *" }, "job_type": "shell", "command": "echo ok" })) .await .unwrap(); assert!(result.success, "{:?}", result.error); assert!(result.output.contains("next_run")); } #[tokio::test] async fn blocks_disallowed_shell_command() { let tmp = TempDir::new().unwrap(); let mut config = Config { workspace_dir: tmp.path().join("workspace"), config_path: tmp.path().join("config.toml"), ..Config::default() }; config.autonomy.allowed_commands = vec!["echo".into()]; config.autonomy.level = AutonomyLevel::Supervised; tokio::fs::create_dir_all(&config.workspace_dir).await.unwrap(); let cfg = Arc::new(config); let tool = CronAddTool::new(cfg.clone(), test_security(&cfg)); let result = tool .execute(json!({ "schedule": { "kind": "cron", "expr": "*/5 * * * *" }, "job_type": "shell", "command": "curl https://example.com" })) .await .unwrap(); assert!(!result.success); assert!(result .error .unwrap_or_default() .contains("blocked by security policy")); } #[tokio::test] async fn rejects_invalid_schedule() { let tmp = TempDir::new().unwrap(); let cfg = test_config(&tmp).await; let tool = CronAddTool::new(cfg.clone(), test_security(&cfg)); let result = tool .execute(json!({ "schedule": { "kind": "every", "every_ms": 0 }, "job_type": "shell", "command": "echo nope" })) .await .unwrap(); assert!(!result.success); assert!(result .error .unwrap_or_default() .contains("every_ms must be > 0")); } #[tokio::test] async fn agent_job_requires_prompt() { let tmp = TempDir::new().unwrap(); let cfg = test_config(&tmp).await; let tool = CronAddTool::new(cfg.clone(), test_security(&cfg)); let result = tool .execute(json!({ "schedule": { "kind": "cron", "expr": "*/5 * * * *" }, "job_type": "agent" })) .await .unwrap(); assert!(!result.success); assert!(result .error .unwrap_or_default() .contains("Missing 'prompt'")); } }