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 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 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 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}