Skip to main content

zeroclaw_runtime/tools/
cron_run.rs

1use crate::cron::{self, JobType};
2use crate::security::SecurityPolicy;
3use async_trait::async_trait;
4use chrono::Utc;
5use serde_json::json;
6use std::sync::Arc;
7use zeroclaw_api::tool::{Tool, ToolResult};
8use zeroclaw_config::schema::Config;
9
10pub struct CronRunTool {
11    config: Arc<Config>,
12    security: Arc<SecurityPolicy>,
13}
14
15impl CronRunTool {
16    pub fn new(config: Arc<Config>, security: Arc<SecurityPolicy>) -> Self {
17        Self { config, security }
18    }
19}
20
21#[async_trait]
22impl Tool for CronRunTool {
23    fn name(&self) -> &str {
24        "cron_run"
25    }
26
27    fn description(&self) -> &str {
28        "Force-run a cron job immediately and record run history"
29    }
30
31    fn parameters_schema(&self) -> serde_json::Value {
32        json!({
33            "type": "object",
34            "properties": {
35                "job_id": { "type": "string" },
36                "approved": {
37                    "type": "boolean",
38                    "description": "Set true to explicitly approve medium/high-risk shell commands in supervised mode",
39                    "default": false
40                }
41            },
42            "required": ["job_id"]
43        })
44    }
45
46    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
47        if !self.config.scheduler.enabled {
48            return Ok(ToolResult {
49                success: false,
50                output: String::new(),
51                error: Some("cron is disabled by config (scheduler.enabled=false)".to_string()),
52            });
53        }
54
55        let job_id = match args.get("job_id").and_then(serde_json::Value::as_str) {
56            Some(v) if !v.trim().is_empty() => v,
57            _ => {
58                return Ok(ToolResult {
59                    success: false,
60                    output: String::new(),
61                    error: Some("Missing 'job_id' parameter".to_string()),
62                });
63            }
64        };
65        let approved = args
66            .get("approved")
67            .and_then(serde_json::Value::as_bool)
68            .unwrap_or(false);
69
70        if !self.security.can_act() {
71            return Ok(ToolResult {
72                success: false,
73                output: String::new(),
74                error: Some("Security policy: read-only mode, cannot perform 'cron_run'".into()),
75            });
76        }
77
78        if self.security.is_rate_limited() {
79            return Ok(ToolResult {
80                success: false,
81                output: String::new(),
82                error: Some("Rate limit exceeded: too many actions in the last hour".into()),
83            });
84        }
85
86        let job = match cron::get_job(&self.config, job_id) {
87            Ok(job) => job,
88            Err(e) => {
89                return Ok(ToolResult {
90                    success: false,
91                    output: String::new(),
92                    error: Some(e.to_string()),
93                });
94            }
95        };
96
97        if matches!(job.job_type, JobType::Shell)
98            && let Err(reason) = self
99                .security
100                .validate_command_execution(&job.command, approved)
101        {
102            return Ok(ToolResult {
103                success: false,
104                output: String::new(),
105                error: Some(reason),
106            });
107        }
108
109        if !self.security.record_action() {
110            return Ok(ToolResult {
111                success: false,
112                output: String::new(),
113                error: Some("Rate limit exceeded: action budget exhausted".into()),
114            });
115        }
116
117        let started_at = Utc::now();
118        let (mut success, output) =
119            Box::pin(cron::scheduler::execute_job_now(&self.config, &job)).await;
120        let finished_at = Utc::now();
121        let duration_ms = (finished_at - started_at).num_milliseconds();
122        let outcome = cron::scheduler::deliver_and_classify_run_result(
123            &self.config,
124            &job,
125            success,
126            output,
127            cron::scheduler::CronDeliveryContext::ToolManual,
128        )
129        .await;
130        success = outcome.success;
131
132        let _ = cron::record_run(
133            &self.config,
134            &job.id,
135            started_at,
136            finished_at,
137            &outcome.status,
138            Some(&outcome.output),
139            duration_ms,
140        );
141        let _ = cron::record_last_run_with_status(
142            &self.config,
143            &job.id,
144            finished_at,
145            &outcome.status,
146            &outcome.output,
147        );
148
149        Ok(ToolResult {
150            success,
151            output: serde_json::to_string_pretty(&json!({
152                "job_id": job.id,
153                "status": outcome.status,
154                "duration_ms": duration_ms,
155                "output": outcome.output
156            }))?,
157            error: if success {
158                None
159            } else {
160                Some("cron job execution failed".to_string())
161            },
162        })
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::security::AutonomyLevel;
170    use tempfile::TempDir;
171    use zeroclaw_config::schema::Config;
172
173    const TEST_AGENT: &str = "test-agent";
174
175    async fn test_config(tmp: &TempDir) -> Arc<Config> {
176        let mut config = Config {
177            data_dir: tmp.path().join("data"),
178            config_path: tmp.path().join("config.toml"),
179            ..Config::default()
180        };
181        seed_test_agent(&mut config);
182        tokio::fs::create_dir_all(&config.data_dir).await.unwrap();
183        Arc::new(config)
184    }
185
186    fn seed_test_agent(config: &mut Config) {
187        config
188            .risk_profiles
189            .entry(TEST_AGENT.to_string())
190            .or_default();
191        config
192            .runtime_profiles
193            .entry(TEST_AGENT.to_string())
194            .or_default();
195        config
196            .providers
197            .models
198            .ensure("openrouter", TEST_AGENT)
199            .expect("known family");
200        config.agents.entry(TEST_AGENT.to_string()).or_insert(
201            zeroclaw_config::schema::AliasedAgentConfig {
202                model_provider: format!("openrouter.{TEST_AGENT}").into(),
203                risk_profile: TEST_AGENT.to_string(),
204                runtime_profile: TEST_AGENT.to_string(),
205                ..Default::default()
206            },
207        );
208    }
209
210    fn test_security(cfg: &Config) -> Arc<SecurityPolicy> {
211        Arc::new(
212            SecurityPolicy::for_agent(cfg, TEST_AGENT).expect("test-agent has resolvable profiles"),
213        )
214    }
215
216    #[tokio::test]
217    async fn force_runs_job_and_records_history() {
218        let tmp = TempDir::new().unwrap();
219        // Build the config so we can wire the imperative job's UUID
220        // into test-agent's cron_jobs list before wrapping in Arc —
221        // otherwise execute_job_now's reverse-lookup can't find the
222        // owning agent.
223        let mut config = Config {
224            data_dir: tmp.path().join("data"),
225            config_path: tmp.path().join("config.toml"),
226            ..Config::default()
227        };
228        seed_test_agent(&mut config);
229        tokio::fs::create_dir_all(&config.data_dir).await.unwrap();
230        let job = cron::add_job(&config, TEST_AGENT, "*/5 * * * *", "echo run-now").unwrap();
231        config
232            .agents
233            .get_mut(TEST_AGENT)
234            .unwrap()
235            .cron_jobs
236            .push(job.id.clone());
237        let cfg = Arc::new(config);
238        let tool = CronRunTool::new(cfg.clone(), test_security(&cfg));
239
240        let result = tool.execute(json!({ "job_id": job.id })).await.unwrap();
241        assert!(result.success, "{:?}", result.error);
242
243        let runs = cron::list_runs(&cfg, &job.id, 10).unwrap();
244        assert_eq!(runs.len(), 1);
245    }
246
247    #[tokio::test]
248    async fn best_effort_delivery_failure_records_degraded_history() {
249        cron::scheduler::register_delivery_fn(Box::new(
250            |_config, channel, _target, _thread_id, _output| {
251                Box::pin(async move {
252                    if channel == "fail-delivery" {
253                        anyhow::bail!("synthetic delivery failure");
254                    }
255                    Ok(())
256                })
257            },
258        ));
259
260        let tmp = TempDir::new().unwrap();
261        let mut config = Config {
262            data_dir: tmp.path().join("data"),
263            config_path: tmp.path().join("config.toml"),
264            ..Config::default()
265        };
266        seed_test_agent(&mut config);
267        tokio::fs::create_dir_all(&config.data_dir).await.unwrap();
268        let job = cron::add_shell_job_with_approval(
269            &config,
270            TEST_AGENT,
271            None,
272            cron::Schedule::Cron {
273                expr: "*/5 * * * *".into(),
274                tz: None,
275            },
276            "echo run-now",
277            Some(cron::DeliveryConfig {
278                mode: "announce".into(),
279                channel: Some("fail-delivery".into()),
280                to: Some("123456".into()),
281                thread_id: None,
282                best_effort: true,
283            }),
284            true,
285        )
286        .unwrap();
287        config
288            .agents
289            .get_mut(TEST_AGENT)
290            .unwrap()
291            .cron_jobs
292            .push(job.id.clone());
293        let cfg = Arc::new(config);
294        let tool = CronRunTool::new(cfg.clone(), test_security(&cfg));
295
296        let result = tool.execute(json!({ "job_id": job.id })).await.unwrap();
297        assert!(result.success, "{:?}", result.error);
298        let response: serde_json::Value = serde_json::from_str(&result.output).unwrap();
299        assert_eq!(response["status"], "degraded");
300        assert!(
301            response["output"]
302                .as_str()
303                .unwrap_or_default()
304                .contains("delivery failed:")
305        );
306
307        let updated = cron::get_job(&cfg, &job.id).unwrap();
308        assert_eq!(updated.last_status.as_deref(), Some("degraded"));
309        assert!(
310            updated
311                .last_output
312                .as_deref()
313                .unwrap_or_default()
314                .contains("delivery failed:")
315        );
316
317        let runs = cron::list_runs(&cfg, &job.id, 10).unwrap();
318        assert_eq!(runs.len(), 1);
319        assert_eq!(runs[0].status, "degraded");
320        assert!(
321            runs[0]
322                .output
323                .as_deref()
324                .unwrap_or_default()
325                .contains("delivery failed:")
326        );
327    }
328
329    #[tokio::test]
330    async fn errors_for_missing_job() {
331        let tmp = TempDir::new().unwrap();
332        let cfg = test_config(&tmp).await;
333        let tool = CronRunTool::new(cfg.clone(), test_security(&cfg));
334
335        let result = tool
336            .execute(json!({ "job_id": "missing-job-id" }))
337            .await
338            .unwrap();
339        assert!(!result.success);
340        assert!(result.error.unwrap_or_default().contains("not found"));
341    }
342
343    #[tokio::test]
344    async fn blocks_run_in_read_only_mode() {
345        let tmp = TempDir::new().unwrap();
346        let mut config = Config {
347            data_dir: tmp.path().join("data"),
348            config_path: tmp.path().join("config.toml"),
349            ..Config::default()
350        };
351        std::fs::create_dir_all(&config.data_dir).unwrap();
352        seed_test_agent(&mut config);
353        let job = cron::add_job(&config, TEST_AGENT, "*/5 * * * *", "echo run-now").unwrap();
354        config
355            .risk_profiles
356            .entry(TEST_AGENT.into())
357            .or_default()
358            .level = AutonomyLevel::ReadOnly;
359        let cfg = Arc::new(config);
360        let tool = CronRunTool::new(cfg.clone(), test_security(&cfg));
361
362        let result = tool.execute(json!({ "job_id": job.id })).await.unwrap();
363        assert!(!result.success);
364        assert!(result.error.unwrap_or_default().contains("read-only"));
365    }
366
367    #[tokio::test]
368    async fn shell_run_requires_approval_for_medium_risk() {
369        let tmp = TempDir::new().unwrap();
370        let mut config = Config {
371            data_dir: tmp.path().join("data"),
372            config_path: tmp.path().join("config.toml"),
373            ..Config::default()
374        };
375        seed_test_agent(&mut config);
376        config
377            .risk_profiles
378            .entry(TEST_AGENT.into())
379            .or_default()
380            .level = AutonomyLevel::Supervised;
381        config
382            .risk_profiles
383            .entry(TEST_AGENT.into())
384            .or_default()
385            .allowed_commands = vec!["touch".into()];
386        std::fs::create_dir_all(&config.data_dir).unwrap();
387        seed_test_agent(&mut config);
388        let cfg = Arc::new(config);
389        // Create with explicit approval so the job persists for the run test.
390        let job = cron::add_shell_job_with_approval(
391            &cfg,
392            TEST_AGENT,
393            None,
394            cron::Schedule::Cron {
395                expr: "*/5 * * * *".into(),
396                tz: None,
397            },
398            "touch cron-run-approval",
399            None,
400            true,
401        )
402        .unwrap();
403        let tool = CronRunTool::new(cfg.clone(), test_security(&cfg));
404
405        // Without approval, the tool-level policy check blocks medium-risk commands.
406        let denied = tool.execute(json!({ "job_id": job.id })).await.unwrap();
407        assert!(!denied.success);
408        assert!(
409            denied
410                .error
411                .unwrap_or_default()
412                .contains("explicit approval")
413        );
414    }
415
416    #[tokio::test]
417    async fn blocks_run_when_rate_limited() {
418        let tmp = TempDir::new().unwrap();
419        let mut config = Config {
420            data_dir: tmp.path().join("data"),
421            config_path: tmp.path().join("config.toml"),
422            ..Config::default()
423        };
424        seed_test_agent(&mut config);
425        config
426            .risk_profiles
427            .entry(TEST_AGENT.into())
428            .or_default()
429            .level = AutonomyLevel::Full;
430        config
431            .runtime_profiles
432            .entry(TEST_AGENT.into())
433            .or_default()
434            .max_actions_per_hour = 0;
435        std::fs::create_dir_all(&config.data_dir).unwrap();
436        seed_test_agent(&mut config);
437        let cfg = Arc::new(config);
438        let job = cron::add_job(&cfg, TEST_AGENT, "*/5 * * * *", "echo run-now").unwrap();
439        let tool = CronRunTool::new(cfg.clone(), test_security(&cfg));
440
441        let result = tool.execute(json!({ "job_id": job.id })).await.unwrap();
442        assert!(!result.success);
443        assert!(
444            result
445                .error
446                .unwrap_or_default()
447                .contains("Rate limit exceeded")
448        );
449        assert!(cron::list_runs(&cfg, &job.id, 10).unwrap().is_empty());
450    }
451}