Skip to main content

zeroclaw_runtime/tools/
schedule.rs

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
11/// Tool that lets the agent manage recurring and one-shot scheduled tasks.
12pub struct ScheduleTool {
13    security: Arc<SecurityPolicy>,
14    config: Config,
15    /// Owning agent — risk profile gate for shell command validation.
16    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        // Enforce rate-limiting AFTER command/args validation so that invalid
384        // requests do not consume the action budget.  (Fixes #3699)
385        if let Some(blocked) = self.enforce_mutation_allowed(action) {
386            return Ok(blocked);
387        }
388
389        // All job creation routes through validated cron helpers, which enforce
390        // the full security policy (allowlist + risk gate) before persistence.
391        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        // Seed test-agent so ScheduleTool's add_shell_job_with_approval
558        // (which validates against the agent's risk profile) succeeds.
559        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}