1use crate::cron;
2use crate::security::SecurityPolicy;
3use anyhow::Result;
4use async_trait::async_trait;
5use chrono::{DateTime, Utc};
6use serde_json::json;
7use std::sync::Arc;
8use zeroclaw_api::tool::{Tool, ToolResult};
9use zeroclaw_config::schema::Config;
10
11pub struct ScheduleTool {
13 security: Arc<SecurityPolicy>,
14 config: Config,
15 agent_alias: String,
17}
18
19impl ScheduleTool {
20 pub fn new(
21 security: Arc<SecurityPolicy>,
22 config: Config,
23 agent_alias: impl Into<String>,
24 ) -> Self {
25 Self {
26 security,
27 config,
28 agent_alias: agent_alias.into(),
29 }
30 }
31}
32
33#[async_trait]
34impl Tool for ScheduleTool {
35 fn name(&self) -> &str {
36 "schedule"
37 }
38
39 fn description(&self) -> &str {
40 "Manage scheduled shell-only tasks. Actions: create/add/once/list/get/cancel/remove/pause/resume. \
41 WARNING: This tool creates shell jobs whose output is only logged, NOT delivered to any channel. \
42 To send a scheduled message to Discord/Telegram/Slack/Matrix, use the cron_add tool with job_type='agent' \
43 and a delivery config like {\"mode\":\"announce\",\"channel\":\"discord\",\"to\":\"<channel_id>\"}."
44 }
45
46 fn parameters_schema(&self) -> serde_json::Value {
47 json!({
48 "type": "object",
49 "properties": {
50 "action": {
51 "type": "string",
52 "enum": ["create", "add", "once", "list", "get", "cancel", "remove", "pause", "resume"],
53 "description": "Action to perform"
54 },
55 "expression": {
56 "type": "string",
57 "description": "Cron expression for recurring tasks (e.g. '*/5 * * * *')."
58 },
59 "delay": {
60 "type": "string",
61 "description": "Delay for one-shot tasks (e.g. '30m', '2h', '1d')."
62 },
63 "run_at": {
64 "type": "string",
65 "description": "Absolute RFC3339 time for one-shot tasks (e.g. '2030-01-01T00:00:00Z')."
66 },
67 "command": {
68 "type": "string",
69 "description": "Shell command to execute. Required for create/add/once."
70 },
71 "approved": {
72 "type": "boolean",
73 "description": "Set true to explicitly approve medium/high-risk shell commands in supervised mode",
74 "default": false
75 },
76 "id": {
77 "type": "string",
78 "description": "Task ID. Required for get/cancel/remove/pause/resume."
79 }
80 },
81 "required": ["action"]
82 })
83 }
84
85 async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
86 let action = args
87 .get("action")
88 .and_then(|value| value.as_str())
89 .ok_or_else(|| {
90 ::zeroclaw_log::record!(
91 WARN,
92 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
93 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
94 .with_attrs(::serde_json::json!({"param": "action"})),
95 "tool argument validation failed"
96 );
97
98 anyhow::Error::msg("Missing 'action' parameter")
99 })?;
100
101 match action {
102 "list" => self.handle_list(),
103 "get" => {
104 let id = args
105 .get("id")
106 .and_then(|value| value.as_str())
107 .ok_or_else(|| {
108 ::zeroclaw_log::record!(
109 WARN,
110 ::zeroclaw_log::Event::new(
111 module_path!(),
112 ::zeroclaw_log::Action::Reject
113 )
114 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
115 .with_attrs(::serde_json::json!({"param": "id"})),
116 "tool argument validation failed"
117 );
118
119 anyhow::Error::msg("Missing 'id' parameter for get action")
120 })?;
121 self.handle_get(id)
122 }
123 "create" | "add" | "once" => {
124 let approved = args
125 .get("approved")
126 .and_then(serde_json::Value::as_bool)
127 .unwrap_or(false);
128 self.handle_create_like(action, &args, approved)
129 }
130 "cancel" | "remove" => {
131 if let Some(blocked) = self.enforce_mutation_allowed(action) {
132 return Ok(blocked);
133 }
134 let id = args
135 .get("id")
136 .and_then(|value| value.as_str())
137 .ok_or_else(|| {
138 ::zeroclaw_log::record!(
139 WARN,
140 ::zeroclaw_log::Event::new(
141 module_path!(),
142 ::zeroclaw_log::Action::Reject
143 )
144 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
145 .with_attrs(::serde_json::json!({"param": "id"})),
146 "tool argument validation failed"
147 );
148
149 anyhow::Error::msg("Missing 'id' parameter for cancel action")
150 })?;
151 Ok(self.handle_cancel(id))
152 }
153 "pause" => {
154 if let Some(blocked) = self.enforce_mutation_allowed(action) {
155 return Ok(blocked);
156 }
157 let id = args
158 .get("id")
159 .and_then(|value| value.as_str())
160 .ok_or_else(|| {
161 ::zeroclaw_log::record!(
162 WARN,
163 ::zeroclaw_log::Event::new(
164 module_path!(),
165 ::zeroclaw_log::Action::Reject
166 )
167 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
168 .with_attrs(::serde_json::json!({"param": "id"})),
169 "tool argument validation failed"
170 );
171
172 anyhow::Error::msg("Missing 'id' parameter for pause action")
173 })?;
174 Ok(self.handle_pause_resume(id, true))
175 }
176 "resume" => {
177 if let Some(blocked) = self.enforce_mutation_allowed(action) {
178 return Ok(blocked);
179 }
180 let id = args
181 .get("id")
182 .and_then(|value| value.as_str())
183 .ok_or_else(|| {
184 ::zeroclaw_log::record!(
185 WARN,
186 ::zeroclaw_log::Event::new(
187 module_path!(),
188 ::zeroclaw_log::Action::Reject
189 )
190 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
191 .with_attrs(::serde_json::json!({"param": "id"})),
192 "tool argument validation failed"
193 );
194
195 anyhow::Error::msg("Missing 'id' parameter for resume action")
196 })?;
197 Ok(self.handle_pause_resume(id, false))
198 }
199 other => Ok(ToolResult {
200 success: false,
201 output: String::new(),
202 error: Some(format!(
203 "Unknown action '{other}'. Use create/add/once/list/get/cancel/remove/pause/resume."
204 )),
205 }),
206 }
207 }
208}
209
210impl ScheduleTool {
211 fn enforce_mutation_allowed(&self, action: &str) -> Option<ToolResult> {
212 if !self.config.scheduler.enabled {
213 return Some(ToolResult {
214 success: false,
215 output: String::new(),
216 error: Some(format!(
217 "cron is disabled by config (scheduler.enabled=false); cannot perform '{action}'"
218 )),
219 });
220 }
221
222 if !self.security.can_act() {
223 return Some(ToolResult {
224 success: false,
225 output: String::new(),
226 error: Some(format!(
227 "Security policy: read-only mode, cannot perform '{action}'"
228 )),
229 });
230 }
231
232 if !self.security.record_action() {
233 return Some(ToolResult {
234 success: false,
235 output: String::new(),
236 error: Some("Rate limit exceeded: action budget exhausted".to_string()),
237 });
238 }
239
240 None
241 }
242
243 fn handle_list(&self) -> Result<ToolResult> {
244 let jobs = cron::list_jobs(&self.config)?;
245 if jobs.is_empty() {
246 return Ok(ToolResult {
247 success: true,
248 output: "No scheduled jobs.".to_string(),
249 error: None,
250 });
251 }
252
253 let mut lines = Vec::with_capacity(jobs.len());
254 for job in jobs {
255 let paused = !job.enabled;
256 let one_shot = matches!(job.schedule, cron::Schedule::At { .. });
257 let flags = match (paused, one_shot) {
258 (true, true) => " [disabled, one-shot]",
259 (true, false) => " [disabled]",
260 (false, true) => " [one-shot]",
261 (false, false) => "",
262 };
263 let last_run = job
264 .last_run
265 .map_or_else(|| "never".to_string(), |value| value.to_rfc3339());
266 let last_status = job.last_status.unwrap_or_else(|| "n/a".to_string());
267 lines.push(format!(
268 "- {} | {} | next={} | last={} ({}){} | cmd: {}",
269 job.id,
270 job.expression,
271 job.next_run.to_rfc3339(),
272 last_run,
273 last_status,
274 flags,
275 job.command
276 ));
277 }
278
279 Ok(ToolResult {
280 success: true,
281 output: format!("Scheduled jobs ({}):\n{}", lines.len(), lines.join("\n")),
282 error: None,
283 })
284 }
285
286 fn handle_get(&self, id: &str) -> Result<ToolResult> {
287 match cron::get_job(&self.config, id) {
288 Ok(job) => {
289 let detail = json!({
290 "id": job.id,
291 "expression": job.expression,
292 "command": job.command,
293 "next_run": job.next_run.to_rfc3339(),
294 "last_run": job.last_run.map(|value| value.to_rfc3339()),
295 "last_status": job.last_status,
296 "enabled": job.enabled,
297 "one_shot": matches!(job.schedule, cron::Schedule::At { .. }),
298 });
299 Ok(ToolResult {
300 success: true,
301 output: serde_json::to_string_pretty(&detail)?,
302 error: None,
303 })
304 }
305 Err(_) => Ok(ToolResult {
306 success: false,
307 output: String::new(),
308 error: Some(format!("Job '{id}' not found")),
309 }),
310 }
311 }
312
313 fn handle_create_like(
314 &self,
315 action: &str,
316 args: &serde_json::Value,
317 approved: bool,
318 ) -> Result<ToolResult> {
319 let command = args
320 .get("command")
321 .and_then(|value| value.as_str())
322 .filter(|value| !value.trim().is_empty())
323 .ok_or_else(|| {
324 ::zeroclaw_log::record!(
325 WARN,
326 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
327 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
328 .with_attrs(::serde_json::json!({"param": "command"})),
329 "tool argument validation failed"
330 );
331
332 anyhow::Error::msg("Missing or empty 'command' parameter")
333 })?;
334
335 let expression = args.get("expression").and_then(|value| value.as_str());
336 let delay = args.get("delay").and_then(|value| value.as_str());
337 let run_at = args.get("run_at").and_then(|value| value.as_str());
338
339 match action {
340 "add" => {
341 if expression.is_none() || delay.is_some() || run_at.is_some() {
342 return Ok(ToolResult {
343 success: false,
344 output: String::new(),
345 error: Some("'add' requires 'expression' and forbids delay/run_at".into()),
346 });
347 }
348 }
349 "once" => {
350 if expression.is_some() || (delay.is_none() && run_at.is_none()) {
351 return Ok(ToolResult {
352 success: false,
353 output: String::new(),
354 error: Some("'once' requires exactly one of 'delay' or 'run_at'".into()),
355 });
356 }
357 if delay.is_some() && run_at.is_some() {
358 return Ok(ToolResult {
359 success: false,
360 output: String::new(),
361 error: Some("'once' supports either delay or run_at, not both".into()),
362 });
363 }
364 }
365 _ => {
366 let count = [expression.is_some(), delay.is_some(), run_at.is_some()]
367 .into_iter()
368 .filter(|value| *value)
369 .count();
370 if count != 1 {
371 return Ok(ToolResult {
372 success: false,
373 output: String::new(),
374 error: Some(
375 "Exactly one of 'expression', 'delay', or 'run_at' must be provided"
376 .into(),
377 ),
378 });
379 }
380 }
381 }
382
383 if let Some(blocked) = self.enforce_mutation_allowed(action) {
386 return Ok(blocked);
387 }
388
389 if let Some(value) = expression {
392 let job = match cron::add_shell_job_with_approval(
393 &self.config,
394 &self.agent_alias,
395 None,
396 cron::Schedule::Cron {
397 expr: value.to_string(),
398 tz: None,
399 },
400 command,
401 None,
402 approved,
403 ) {
404 Ok(job) => job,
405 Err(error) => {
406 return Ok(ToolResult {
407 success: false,
408 output: String::new(),
409 error: Some(error.to_string()),
410 });
411 }
412 };
413 return Ok(ToolResult {
414 success: true,
415 output: format!(
416 "Created recurring job {} (expr: {}, next: {}, cmd: {})",
417 job.id,
418 job.expression,
419 job.next_run.to_rfc3339(),
420 job.command
421 ),
422 error: None,
423 });
424 }
425
426 if let Some(value) = delay {
427 let job = match cron::add_once_validated(
428 &self.config,
429 &self.agent_alias,
430 value,
431 command,
432 approved,
433 ) {
434 Ok(job) => job,
435 Err(error) => {
436 return Ok(ToolResult {
437 success: false,
438 output: String::new(),
439 error: Some(error.to_string()),
440 });
441 }
442 };
443 return Ok(ToolResult {
444 success: true,
445 output: format!(
446 "Created one-shot job {} (runs at: {}, cmd: {})",
447 job.id,
448 job.next_run.to_rfc3339(),
449 job.command
450 ),
451 error: None,
452 });
453 }
454
455 let run_at_raw = run_at.ok_or_else(|| {
456 ::zeroclaw_log::record!(
457 WARN,
458 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
459 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
460 "schedule tool: missing scheduling parameters (run_at / delay_seconds)"
461 );
462 anyhow::Error::msg("Missing scheduling parameters")
463 })?;
464 let run_at_parsed: DateTime<Utc> = DateTime::parse_from_rfc3339(run_at_raw)
465 .map_err(|error| {
466 ::zeroclaw_log::record!(
467 WARN,
468 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
469 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
470 .with_attrs(::serde_json::json!({
471 "run_at": run_at_raw,
472 "error": format!("{}", error),
473 })),
474 "schedule tool: invalid run_at timestamp"
475 );
476 anyhow::Error::msg(format!("Invalid run_at timestamp: {error}"))
477 })?
478 .with_timezone(&Utc);
479
480 let job = match cron::add_once_at_validated(
481 &self.config,
482 &self.agent_alias,
483 run_at_parsed,
484 command,
485 approved,
486 ) {
487 Ok(job) => job,
488 Err(error) => {
489 return Ok(ToolResult {
490 success: false,
491 output: String::new(),
492 error: Some(error.to_string()),
493 });
494 }
495 };
496 Ok(ToolResult {
497 success: true,
498 output: format!(
499 "Created one-shot job {} (runs at: {}, cmd: {})",
500 job.id,
501 job.next_run.to_rfc3339(),
502 job.command
503 ),
504 error: None,
505 })
506 }
507
508 fn handle_cancel(&self, id: &str) -> ToolResult {
509 match cron::remove_job(&self.config, id) {
510 Ok(()) => ToolResult {
511 success: true,
512 output: format!("Cancelled job {id}"),
513 error: None,
514 },
515 Err(error) => ToolResult {
516 success: false,
517 output: String::new(),
518 error: Some(error.to_string()),
519 },
520 }
521 }
522
523 fn handle_pause_resume(&self, id: &str, pause: bool) -> ToolResult {
524 let operation = if pause {
525 cron::pause_job(&self.config, id)
526 } else {
527 cron::resume_job(&self.config, id)
528 };
529
530 match operation {
531 Ok(_) => ToolResult {
532 success: true,
533 output: if pause {
534 format!("Paused job {id}")
535 } else {
536 format!("Resumed job {id}")
537 },
538 error: None,
539 },
540 Err(error) => ToolResult {
541 success: false,
542 output: String::new(),
543 error: Some(error.to_string()),
544 },
545 }
546 }
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552 use crate::security::AutonomyLevel;
553 use tempfile::TempDir;
554
555 async fn test_setup() -> (TempDir, Config, Arc<SecurityPolicy>) {
556 let tmp = TempDir::new().unwrap();
557 let config = config_with_test_agent_profiles(
560 tmp.path().join("workspace"),
561 tmp.path().join("config.toml"),
562 zeroclaw_config::schema::RiskProfileConfig::default(),
563 zeroclaw_config::schema::RuntimeProfileConfig::default(),
564 );
565 tokio::fs::create_dir_all(&config.data_dir).await.unwrap();
566 let security = Arc::new(SecurityPolicy::for_agent(&config, TEST_AGENT).unwrap());
567 (tmp, config, security)
568 }
569
570 #[tokio::test]
571 async fn tool_name_and_schema() {
572 let (_tmp, config, security) = test_setup().await;
573 let tool = ScheduleTool::new(security, config, TEST_AGENT);
574 assert_eq!(tool.name(), "schedule");
575 let schema = tool.parameters_schema();
576 assert!(schema["properties"]["action"].is_object());
577 }
578
579 #[tokio::test]
580 async fn list_empty() {
581 let (_tmp, config, security) = test_setup().await;
582 let tool = ScheduleTool::new(security, config, TEST_AGENT);
583
584 let result = tool.execute(json!({"action": "list"})).await.unwrap();
585 assert!(result.success);
586 assert!(result.output.contains("No scheduled jobs"));
587 }
588
589 #[tokio::test]
590 async fn create_get_and_cancel_roundtrip() {
591 let (_tmp, config, security) = test_setup().await;
592 let tool = ScheduleTool::new(security, config, TEST_AGENT);
593
594 let create = tool
595 .execute(json!({
596 "action": "create",
597 "expression": "*/5 * * * *",
598 "command": "echo hello"
599 }))
600 .await
601 .unwrap();
602 assert!(create.success);
603 assert!(create.output.contains("Created recurring job"));
604
605 let list = tool.execute(json!({"action": "list"})).await.unwrap();
606 assert!(list.success);
607 assert!(list.output.contains("echo hello"));
608
609 let id = create.output.split_whitespace().nth(3).unwrap();
610
611 let get = tool
612 .execute(json!({"action": "get", "id": id}))
613 .await
614 .unwrap();
615 assert!(get.success);
616 assert!(get.output.contains("echo hello"));
617
618 let cancel = tool
619 .execute(json!({"action": "cancel", "id": id}))
620 .await
621 .unwrap();
622 assert!(cancel.success);
623 }
624
625 #[tokio::test]
626 async fn once_and_pause_resume_aliases_work() {
627 let (_tmp, config, security) = test_setup().await;
628 let tool = ScheduleTool::new(security, config, TEST_AGENT);
629
630 let once = tool
631 .execute(json!({
632 "action": "once",
633 "delay": "30m",
634 "command": "echo delayed"
635 }))
636 .await
637 .unwrap();
638 assert!(once.success);
639
640 let add = tool
641 .execute(json!({
642 "action": "add",
643 "expression": "*/10 * * * *",
644 "command": "echo recurring"
645 }))
646 .await
647 .unwrap();
648 assert!(add.success);
649
650 let id = add.output.split_whitespace().nth(3).unwrap();
651 let pause = tool
652 .execute(json!({"action": "pause", "id": id}))
653 .await
654 .unwrap();
655 assert!(pause.success);
656
657 let resume = tool
658 .execute(json!({"action": "resume", "id": id}))
659 .await
660 .unwrap();
661 assert!(resume.success);
662 }
663
664 const TEST_AGENT: &str = "test-agent";
665
666 fn config_with_test_agent_profiles(
667 workspace: std::path::PathBuf,
668 config_path: std::path::PathBuf,
669 risk: zeroclaw_config::schema::RiskProfileConfig,
670 runtime: zeroclaw_config::schema::RuntimeProfileConfig,
671 ) -> Config {
672 let mut config = Config {
673 data_dir: workspace,
674 config_path,
675 ..Config::default()
676 };
677 config.risk_profiles.insert(TEST_AGENT.into(), risk);
678 config.runtime_profiles.insert(TEST_AGENT.into(), runtime);
679 seed_test_agent_provider_and_agent(&mut config);
680 config
681 }
682
683 fn seed_test_agent_provider_and_agent(config: &mut Config) {
684 config
685 .risk_profiles
686 .entry(TEST_AGENT.to_string())
687 .or_default();
688 config
689 .runtime_profiles
690 .entry(TEST_AGENT.to_string())
691 .or_default();
692 config
693 .providers
694 .models
695 .ensure("openrouter", TEST_AGENT)
696 .expect("known family");
697 config.agents.entry(TEST_AGENT.to_string()).or_insert(
698 zeroclaw_config::schema::AliasedAgentConfig {
699 model_provider: format!("openrouter.{TEST_AGENT}").into(),
700 risk_profile: TEST_AGENT.to_string(),
701 runtime_profile: TEST_AGENT.to_string(),
702 ..Default::default()
703 },
704 );
705 }
706
707 #[tokio::test]
708 async fn readonly_blocks_mutating_actions() {
709 let tmp = TempDir::new().unwrap();
710 let config = config_with_test_agent_profiles(
711 tmp.path().join("workspace"),
712 tmp.path().join("config.toml"),
713 zeroclaw_config::schema::RiskProfileConfig {
714 level: AutonomyLevel::ReadOnly,
715 ..Default::default()
716 },
717 zeroclaw_config::schema::RuntimeProfileConfig::default(),
718 );
719 tokio::fs::create_dir_all(&config.data_dir).await.unwrap();
720 let security = Arc::new(SecurityPolicy::for_agent(&config, TEST_AGENT).unwrap());
721
722 let tool = ScheduleTool::new(security, config, TEST_AGENT);
723
724 let blocked = tool
725 .execute(json!({
726 "action": "create",
727 "expression": "* * * * *",
728 "command": "echo blocked"
729 }))
730 .await
731 .unwrap();
732 assert!(!blocked.success);
733 assert!(blocked.error.as_deref().unwrap().contains("read-only"));
734
735 let list = tool.execute(json!({"action": "list"})).await.unwrap();
736 assert!(list.success);
737 }
738
739 #[tokio::test]
740 async fn rate_limit_blocks_create_action() {
741 let tmp = TempDir::new().unwrap();
742 let config = config_with_test_agent_profiles(
743 tmp.path().join("workspace"),
744 tmp.path().join("config.toml"),
745 zeroclaw_config::schema::RiskProfileConfig {
746 level: AutonomyLevel::Full,
747 ..Default::default()
748 },
749 zeroclaw_config::schema::RuntimeProfileConfig {
750 max_actions_per_hour: 0,
751 ..Default::default()
752 },
753 );
754 tokio::fs::create_dir_all(&config.data_dir).await.unwrap();
755 let security = Arc::new(SecurityPolicy::for_agent(&config, TEST_AGENT).unwrap());
756 let tool = ScheduleTool::new(security, config, TEST_AGENT);
757
758 let blocked = tool
759 .execute(json!({
760 "action": "create",
761 "expression": "*/5 * * * *",
762 "command": "echo blocked-by-rate-limit"
763 }))
764 .await
765 .unwrap();
766 assert!(!blocked.success);
767 assert!(
768 blocked
769 .error
770 .as_deref()
771 .unwrap_or_default()
772 .contains("Rate limit exceeded")
773 );
774
775 let list = tool.execute(json!({"action": "list"})).await.unwrap();
776 assert!(list.success);
777 assert!(list.output.contains("No scheduled jobs"));
778 }
779
780 #[tokio::test]
781 async fn rate_limit_blocks_cancel_and_keeps_job() {
782 let tmp = TempDir::new().unwrap();
783 let config = config_with_test_agent_profiles(
784 tmp.path().join("workspace"),
785 tmp.path().join("config.toml"),
786 zeroclaw_config::schema::RiskProfileConfig {
787 level: AutonomyLevel::Full,
788 ..Default::default()
789 },
790 zeroclaw_config::schema::RuntimeProfileConfig {
791 max_actions_per_hour: 1,
792 ..Default::default()
793 },
794 );
795 tokio::fs::create_dir_all(&config.data_dir).await.unwrap();
796 let security = Arc::new(SecurityPolicy::for_agent(&config, TEST_AGENT).unwrap());
797 let tool = ScheduleTool::new(security, config, TEST_AGENT);
798
799 let create = tool
800 .execute(json!({
801 "action": "create",
802 "expression": "*/5 * * * *",
803 "command": "echo keep-me"
804 }))
805 .await
806 .unwrap();
807 assert!(create.success);
808 let id = create.output.split_whitespace().nth(3).unwrap();
809
810 let cancel = tool
811 .execute(json!({"action": "cancel", "id": id}))
812 .await
813 .unwrap();
814 assert!(!cancel.success);
815 assert!(
816 cancel
817 .error
818 .as_deref()
819 .unwrap_or_default()
820 .contains("Rate limit exceeded")
821 );
822
823 let get = tool
824 .execute(json!({"action": "get", "id": id}))
825 .await
826 .unwrap();
827 assert!(get.success);
828 assert!(get.output.contains("echo keep-me"));
829 }
830
831 #[tokio::test]
832 async fn unknown_action_returns_failure() {
833 let (_tmp, config, security) = test_setup().await;
834 let tool = ScheduleTool::new(security, config, TEST_AGENT);
835
836 let result = tool.execute(json!({"action": "explode"})).await.unwrap();
837 assert!(!result.success);
838 assert!(result.error.as_deref().unwrap().contains("Unknown action"));
839 }
840
841 #[tokio::test]
842 async fn mutating_actions_fail_when_cron_disabled() {
843 let tmp = TempDir::new().unwrap();
844 let mut config = Config {
845 data_dir: tmp.path().join("data"),
846 config_path: tmp.path().join("config.toml"),
847 ..Config::default()
848 };
849 config.scheduler.enabled = false;
850 seed_test_agent_provider_and_agent(&mut config);
851 std::fs::create_dir_all(&config.data_dir).unwrap();
852 let security = Arc::new(SecurityPolicy::for_agent(&config, TEST_AGENT).unwrap());
853 let tool = ScheduleTool::new(security, config, TEST_AGENT);
854
855 let create = tool
856 .execute(json!({
857 "action": "create",
858 "expression": "*/5 * * * *",
859 "command": "echo hello"
860 }))
861 .await
862 .unwrap();
863
864 assert!(!create.success);
865 assert!(
866 create
867 .error
868 .as_deref()
869 .unwrap_or_default()
870 .contains("cron is disabled")
871 );
872 }
873
874 #[tokio::test]
875 async fn create_blocks_disallowed_command() {
876 let tmp = TempDir::new().unwrap();
877 let mut config = Config {
878 data_dir: tmp.path().join("data"),
879 config_path: tmp.path().join("config.toml"),
880 ..Config::default()
881 };
882 config
883 .risk_profiles
884 .entry(TEST_AGENT.into())
885 .or_default()
886 .level = AutonomyLevel::Supervised;
887 config
888 .risk_profiles
889 .entry(TEST_AGENT.into())
890 .or_default()
891 .allowed_commands = vec!["echo".into()];
892 seed_test_agent_provider_and_agent(&mut config);
893 std::fs::create_dir_all(&config.data_dir).unwrap();
894 let security = Arc::new(SecurityPolicy::for_agent(&config, TEST_AGENT).unwrap());
895 let tool = ScheduleTool::new(security, config, TEST_AGENT);
896
897 let result = tool
898 .execute(json!({
899 "action": "create",
900 "expression": "*/5 * * * *",
901 "command": "curl https://example.com"
902 }))
903 .await
904 .unwrap();
905
906 assert!(!result.success);
907 assert!(
908 result
909 .error
910 .as_deref()
911 .unwrap_or_default()
912 .contains("not allowed")
913 );
914 }
915
916 #[tokio::test]
917 async fn medium_risk_create_requires_approval() {
918 let tmp = TempDir::new().unwrap();
919 let mut config = Config {
920 data_dir: tmp.path().join("data"),
921 config_path: tmp.path().join("config.toml"),
922 ..Config::default()
923 };
924 config
925 .risk_profiles
926 .entry(TEST_AGENT.into())
927 .or_default()
928 .level = AutonomyLevel::Supervised;
929 config
930 .risk_profiles
931 .entry(TEST_AGENT.into())
932 .or_default()
933 .allowed_commands = vec!["touch".into()];
934 seed_test_agent_provider_and_agent(&mut config);
935 std::fs::create_dir_all(&config.data_dir).unwrap();
936 let security = Arc::new(SecurityPolicy::for_agent(&config, TEST_AGENT).unwrap());
937 let tool = ScheduleTool::new(security, config, TEST_AGENT);
938
939 let denied = tool
940 .execute(json!({
941 "action": "create",
942 "expression": "*/5 * * * *",
943 "command": "touch schedule-policy-test"
944 }))
945 .await
946 .unwrap();
947 assert!(!denied.success);
948 assert!(
949 denied
950 .error
951 .as_deref()
952 .unwrap_or_default()
953 .contains("explicit approval")
954 );
955
956 let approved = tool
957 .execute(json!({
958 "action": "create",
959 "expression": "*/5 * * * *",
960 "command": "touch schedule-policy-test",
961 "approved": true
962 }))
963 .await
964 .unwrap();
965 assert!(approved.success, "{:?}", approved.error);
966 }
967}