Skip to main content

construct/tools/
cron_add.rs

1use super::traits::{Tool, ToolResult};
2use crate::config::Config;
3use crate::cron::{
4    self, DeliveryConfig, JobType, Schedule, SessionTarget, deserialize_maybe_stringified,
5};
6use crate::security::SecurityPolicy;
7use async_trait::async_trait;
8use serde_json::json;
9use std::sync::Arc;
10
11pub struct CronAddTool {
12    config: Arc<Config>,
13    security: Arc<SecurityPolicy>,
14}
15
16impl CronAddTool {
17    pub fn new(config: Arc<Config>, security: Arc<SecurityPolicy>) -> Self {
18        Self { config, security }
19    }
20
21    fn enforce_mutation_allowed(&self, action: &str) -> Option<ToolResult> {
22        if !self.security.can_act() {
23            return Some(ToolResult {
24                success: false,
25                output: String::new(),
26                error: Some(format!(
27                    "Security policy: read-only mode, cannot perform '{action}'"
28                )),
29            });
30        }
31
32        if self.security.is_rate_limited() {
33            return Some(ToolResult {
34                success: false,
35                output: String::new(),
36                error: Some("Rate limit exceeded: too many actions in the last hour".to_string()),
37            });
38        }
39
40        if !self.security.record_action() {
41            return Some(ToolResult {
42                success: false,
43                output: String::new(),
44                error: Some("Rate limit exceeded: action budget exhausted".to_string()),
45            });
46        }
47
48        None
49    }
50}
51
52#[async_trait]
53impl Tool for CronAddTool {
54    fn name(&self) -> &str {
55        "cron_add"
56    }
57
58    fn description(&self) -> &str {
59        "Create a scheduled cron job (shell or agent) with cron/at/every schedules. \
60         Use job_type='agent' with a prompt to run the AI agent on schedule. \
61         To deliver output to a channel (Discord, Telegram, Slack, Mattermost, Matrix, QQ), set \
62         delivery={\"mode\":\"announce\",\"channel\":\"discord\",\"to\":\"<channel_id_or_chat_id>\"}. \
63         This is the preferred tool for sending scheduled/delayed messages to users via channels."
64    }
65
66    fn parameters_schema(&self) -> serde_json::Value {
67        json!({
68            "type": "object",
69            "properties": {
70                "name": {
71                    "type": "string",
72                    "description": "Optional human-readable name for the job"
73                },
74                // NOTE: oneOf is correct for OpenAI-compatible APIs (including OpenRouter).
75                // Gemini does not support oneOf in tool schemas; if Gemini native tool calling
76                // is ever wired up, SchemaCleanr::clean_for_gemini must be applied before
77                // tool specs are sent. See src/tools/schema.rs.
78                "schedule": {
79                    "description": "When to run the job. Exactly one of three forms must be used.",
80                    "oneOf": [
81                        {
82                            "type": "object",
83                            "description": "Cron expression schedule (repeating). Example: {\"kind\":\"cron\",\"expr\":\"0 9 * * 1-5\",\"tz\":\"America/New_York\"}",
84                            "properties": {
85                                "kind": { "type": "string", "enum": ["cron"] },
86                                "expr": { "type": "string", "description": "Standard 5-field cron expression, e.g. '*/5 * * * *'" },
87                                "tz": { "type": "string", "description": "Optional IANA timezone name, e.g. 'America/New_York'. Defaults to UTC." }
88                            },
89                            "required": ["kind", "expr"]
90                        },
91                        {
92                            "type": "object",
93                            "description": "One-shot schedule at a specific UTC datetime. Example: {\"kind\":\"at\",\"at\":\"2025-12-31T23:59:00Z\"}",
94                            "properties": {
95                                "kind": { "type": "string", "enum": ["at"] },
96                                "at": { "type": "string", "description": "ISO 8601 UTC datetime string, e.g. '2025-12-31T23:59:00Z'" }
97                            },
98                            "required": ["kind", "at"]
99                        },
100                        {
101                            "type": "object",
102                            "description": "Repeating interval schedule in milliseconds. Example: {\"kind\":\"every\",\"every_ms\":3600000} runs every hour.",
103                            "properties": {
104                                "kind": { "type": "string", "enum": ["every"] },
105                                "every_ms": { "type": "integer", "description": "Interval in milliseconds, e.g. 3600000 for every hour" }
106                            },
107                            "required": ["kind", "every_ms"]
108                        }
109                    ]
110                },
111                "job_type": {
112                    "type": "string",
113                    "enum": ["shell", "agent"],
114                    "description": "Type of job: 'shell' runs a command, 'agent' runs the AI agent with a prompt"
115                },
116                "command": {
117                    "type": "string",
118                    "description": "Shell command to run (required when job_type is 'shell')"
119                },
120                "prompt": {
121                    "type": "string",
122                    "description": "Agent prompt to run on schedule (required when job_type is 'agent')"
123                },
124                "session_target": {
125                    "type": "string",
126                    "enum": ["isolated", "main"],
127                    "description": "Agent session context: 'isolated' starts a fresh session each run, 'main' reuses the primary session"
128                },
129                "model": {
130                    "type": "string",
131                    "description": "Optional model override for agent jobs, e.g. 'x-ai/grok-4-1-fast'"
132                },
133                "allowed_tools": {
134                    "type": "array",
135                    "items": { "type": "string" },
136                    "description": "Optional allowlist of tool names for agent jobs. When omitted, all tools remain available."
137                },
138                "delivery": {
139                    "type": "object",
140                    "description": "Optional delivery config to send job output to a channel after each run. When provided, all three of mode, channel, and to are expected.",
141                    "properties": {
142                        "mode": {
143                            "type": "string",
144                            "enum": ["none", "announce"],
145                            "description": "'announce' sends output to the specified channel; 'none' disables delivery"
146                        },
147                        "channel": {
148                            "type": "string",
149                            "enum": ["telegram", "discord", "slack", "mattermost", "matrix", "qq"],
150                            "description": "Channel type to deliver output to"
151                        },
152                        "to": {
153                            "type": "string",
154                            "description": "Destination ID: Discord channel ID, Telegram chat ID, Slack channel name, etc."
155                        },
156                        "best_effort": {
157                            "type": "boolean",
158                            "description": "If true, a delivery failure does not fail the job itself. Defaults to true."
159                        }
160                    }
161                },
162                "delete_after_run": {
163                    "type": "boolean",
164                    "description": "If true, the job is automatically deleted after its first successful run. Defaults to true for 'at' schedules."
165                },
166                "approved": {
167                    "type": "boolean",
168                    "description": "Set true to explicitly approve medium/high-risk shell commands in supervised mode",
169                    "default": false
170                }
171            },
172            "required": ["schedule"]
173        })
174    }
175
176    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
177        if !self.config.cron.enabled {
178            return Ok(ToolResult {
179                success: false,
180                output: String::new(),
181                error: Some("cron is disabled by config (cron.enabled=false)".to_string()),
182            });
183        }
184
185        let schedule = match args.get("schedule") {
186            Some(v) => match deserialize_maybe_stringified::<Schedule>(v) {
187                Ok(schedule) => schedule,
188                Err(e) => {
189                    return Ok(ToolResult {
190                        success: false,
191                        output: String::new(),
192                        error: Some(format!("Invalid schedule: {e}")),
193                    });
194                }
195            },
196            None => {
197                return Ok(ToolResult {
198                    success: false,
199                    output: String::new(),
200                    error: Some("Missing 'schedule' parameter".to_string()),
201                });
202            }
203        };
204
205        let name = args
206            .get("name")
207            .and_then(serde_json::Value::as_str)
208            .map(str::to_string);
209
210        let job_type = match args.get("job_type").and_then(serde_json::Value::as_str) {
211            Some("agent") => JobType::Agent,
212            Some("shell") => JobType::Shell,
213            Some(other) => {
214                return Ok(ToolResult {
215                    success: false,
216                    output: String::new(),
217                    error: Some(format!("Invalid job_type: {other}")),
218                });
219            }
220            None => {
221                if args.get("prompt").is_some() {
222                    JobType::Agent
223                } else {
224                    JobType::Shell
225                }
226            }
227        };
228
229        let default_delete_after_run = matches!(schedule, Schedule::At { .. });
230        let delete_after_run = args
231            .get("delete_after_run")
232            .and_then(serde_json::Value::as_bool)
233            .unwrap_or(default_delete_after_run);
234        let approved = args
235            .get("approved")
236            .and_then(serde_json::Value::as_bool)
237            .unwrap_or(false);
238        let delivery = match args.get("delivery") {
239            Some(v) => match serde_json::from_value::<DeliveryConfig>(v.clone()) {
240                Ok(cfg) => Some(cfg),
241                Err(e) => {
242                    return Ok(ToolResult {
243                        success: false,
244                        output: String::new(),
245                        error: Some(format!("Invalid delivery config: {e}")),
246                    });
247                }
248            },
249            None => None,
250        };
251
252        let result = match job_type {
253            JobType::Shell => {
254                let command = match args.get("command").and_then(serde_json::Value::as_str) {
255                    Some(command) if !command.trim().is_empty() => command,
256                    _ => {
257                        return Ok(ToolResult {
258                            success: false,
259                            output: String::new(),
260                            error: Some("Missing 'command' for shell job".to_string()),
261                        });
262                    }
263                };
264
265                if let Err(reason) = self.security.validate_command_execution(command, approved) {
266                    return Ok(ToolResult {
267                        success: false,
268                        output: String::new(),
269                        error: Some(reason),
270                    });
271                }
272
273                if let Some(blocked) = self.enforce_mutation_allowed("cron_add") {
274                    return Ok(blocked);
275                }
276
277                cron::add_shell_job_with_approval(
278                    &self.config,
279                    name,
280                    schedule,
281                    command,
282                    delivery,
283                    approved,
284                )
285            }
286            JobType::Agent => {
287                let prompt = match args.get("prompt").and_then(serde_json::Value::as_str) {
288                    Some(prompt) if !prompt.trim().is_empty() => prompt,
289                    _ => {
290                        return Ok(ToolResult {
291                            success: false,
292                            output: String::new(),
293                            error: Some("Missing 'prompt' for agent job".to_string()),
294                        });
295                    }
296                };
297
298                let session_target = match args.get("session_target") {
299                    Some(v) => match serde_json::from_value::<SessionTarget>(v.clone()) {
300                        Ok(target) => target,
301                        Err(e) => {
302                            return Ok(ToolResult {
303                                success: false,
304                                output: String::new(),
305                                error: Some(format!("Invalid session_target: {e}")),
306                            });
307                        }
308                    },
309                    None => SessionTarget::Isolated,
310                };
311
312                let model = args
313                    .get("model")
314                    .and_then(serde_json::Value::as_str)
315                    .map(str::to_string);
316                let allowed_tools = match args.get("allowed_tools") {
317                    Some(v) => match serde_json::from_value::<Vec<String>>(v.clone()) {
318                        Ok(v) => {
319                            if v.is_empty() {
320                                None // Treat empty list same as unset
321                            } else {
322                                Some(v)
323                            }
324                        }
325                        Err(e) => {
326                            return Ok(ToolResult {
327                                success: false,
328                                output: String::new(),
329                                error: Some(format!("Invalid allowed_tools: {e}")),
330                            });
331                        }
332                    },
333                    None => None,
334                };
335
336                if let Some(blocked) = self.enforce_mutation_allowed("cron_add") {
337                    return Ok(blocked);
338                }
339
340                cron::add_agent_job(
341                    &self.config,
342                    name,
343                    schedule,
344                    prompt,
345                    session_target,
346                    model,
347                    delivery,
348                    delete_after_run,
349                    allowed_tools,
350                )
351            }
352            JobType::Workflow => {
353                return Ok(ToolResult {
354                    success: false,
355                    output: String::new(),
356                    error: Some(
357                        "Workflow cron jobs are managed via workflow YAML triggers, \
358                         not the cron_add tool"
359                            .to_string(),
360                    ),
361                });
362            }
363        };
364
365        match result {
366            Ok(job) => Ok(ToolResult {
367                success: true,
368                output: serde_json::to_string_pretty(&json!({
369                    "id": job.id,
370                    "name": job.name,
371                    "job_type": job.job_type,
372                    "schedule": job.schedule,
373                    "next_run": job.next_run,
374                    "enabled": job.enabled,
375                    "allowed_tools": job.allowed_tools
376                }))?,
377                error: None,
378            }),
379            Err(e) => Ok(ToolResult {
380                success: false,
381                output: String::new(),
382                error: Some(e.to_string()),
383            }),
384        }
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use crate::config::Config;
392    use crate::security::AutonomyLevel;
393    use tempfile::TempDir;
394
395    async fn test_config(tmp: &TempDir) -> Arc<Config> {
396        let config = Config {
397            workspace_dir: tmp.path().join("workspace"),
398            config_path: tmp.path().join("config.toml"),
399            ..Config::default()
400        };
401        tokio::fs::create_dir_all(&config.workspace_dir)
402            .await
403            .unwrap();
404        Arc::new(config)
405    }
406
407    fn test_security(cfg: &Config) -> Arc<SecurityPolicy> {
408        Arc::new(SecurityPolicy::from_config(
409            &cfg.autonomy,
410            &cfg.workspace_dir,
411        ))
412    }
413
414    #[tokio::test]
415    async fn adds_shell_job() {
416        let tmp = TempDir::new().unwrap();
417        let cfg = test_config(&tmp).await;
418        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
419        let result = tool
420            .execute(json!({
421                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
422                "job_type": "shell",
423                "command": "echo ok"
424            }))
425            .await
426            .unwrap();
427
428        assert!(result.success, "{:?}", result.error);
429        assert!(result.output.contains("next_run"));
430    }
431
432    #[tokio::test]
433    async fn shell_job_persists_delivery() {
434        let tmp = TempDir::new().unwrap();
435        let cfg = test_config(&tmp).await;
436        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
437        let result = tool
438            .execute(json!({
439                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
440                "job_type": "shell",
441                "command": "echo ok",
442                "delivery": {
443                    "mode": "announce",
444                    "channel": "discord",
445                    "to": "1234567890",
446                    "best_effort": true
447                }
448            }))
449            .await
450            .unwrap();
451
452        assert!(result.success, "{:?}", result.error);
453
454        let jobs = cron::list_jobs(&cfg).unwrap();
455        assert_eq!(jobs.len(), 1);
456        assert_eq!(jobs[0].delivery.mode, "announce");
457        assert_eq!(jobs[0].delivery.channel.as_deref(), Some("discord"));
458        assert_eq!(jobs[0].delivery.to.as_deref(), Some("1234567890"));
459        assert!(jobs[0].delivery.best_effort);
460    }
461
462    #[tokio::test]
463    async fn blocks_disallowed_shell_command() {
464        let tmp = TempDir::new().unwrap();
465        let mut config = Config {
466            workspace_dir: tmp.path().join("workspace"),
467            config_path: tmp.path().join("config.toml"),
468            ..Config::default()
469        };
470        config.autonomy.allowed_commands = vec!["echo".into()];
471        config.autonomy.level = AutonomyLevel::Supervised;
472        tokio::fs::create_dir_all(&config.workspace_dir)
473            .await
474            .unwrap();
475        let cfg = Arc::new(config);
476        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
477
478        let result = tool
479            .execute(json!({
480                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
481                "job_type": "shell",
482                "command": "curl https://example.com"
483            }))
484            .await
485            .unwrap();
486
487        assert!(!result.success);
488        assert!(result.error.unwrap_or_default().contains("not allowed"));
489    }
490
491    #[tokio::test]
492    async fn blocks_mutation_in_read_only_mode() {
493        let tmp = TempDir::new().unwrap();
494        let mut config = Config {
495            workspace_dir: tmp.path().join("workspace"),
496            config_path: tmp.path().join("config.toml"),
497            ..Config::default()
498        };
499        config.autonomy.level = AutonomyLevel::ReadOnly;
500        std::fs::create_dir_all(&config.workspace_dir).unwrap();
501        let cfg = Arc::new(config);
502        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
503
504        let result = tool
505            .execute(json!({
506                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
507                "job_type": "shell",
508                "command": "echo ok"
509            }))
510            .await
511            .unwrap();
512
513        assert!(!result.success);
514        let error = result.error.unwrap_or_default();
515        assert!(error.contains("read-only") || error.contains("not allowed"));
516    }
517
518    #[tokio::test]
519    async fn blocks_add_when_rate_limited() {
520        let tmp = TempDir::new().unwrap();
521        let mut config = Config {
522            workspace_dir: tmp.path().join("workspace"),
523            config_path: tmp.path().join("config.toml"),
524            ..Config::default()
525        };
526        config.autonomy.level = AutonomyLevel::Full;
527        config.autonomy.max_actions_per_hour = 0;
528        std::fs::create_dir_all(&config.workspace_dir).unwrap();
529        let cfg = Arc::new(config);
530        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
531
532        let result = tool
533            .execute(json!({
534                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
535                "job_type": "shell",
536                "command": "echo ok"
537            }))
538            .await
539            .unwrap();
540
541        assert!(!result.success);
542        assert!(
543            result
544                .error
545                .unwrap_or_default()
546                .contains("Rate limit exceeded")
547        );
548        assert!(cron::list_jobs(&cfg).unwrap().is_empty());
549    }
550
551    #[tokio::test]
552    async fn medium_risk_shell_command_requires_approval() {
553        let tmp = TempDir::new().unwrap();
554        let mut config = Config {
555            workspace_dir: tmp.path().join("workspace"),
556            config_path: tmp.path().join("config.toml"),
557            ..Config::default()
558        };
559        config.autonomy.allowed_commands = vec!["touch".into()];
560        config.autonomy.level = AutonomyLevel::Supervised;
561        std::fs::create_dir_all(&config.workspace_dir).unwrap();
562        let cfg = Arc::new(config);
563        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
564
565        let denied = tool
566            .execute(json!({
567                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
568                "job_type": "shell",
569                "command": "touch cron-approval-test"
570            }))
571            .await
572            .unwrap();
573        assert!(!denied.success);
574        assert!(
575            denied
576                .error
577                .unwrap_or_default()
578                .contains("explicit approval")
579        );
580
581        let approved = tool
582            .execute(json!({
583                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
584                "job_type": "shell",
585                "command": "touch cron-approval-test",
586                "approved": true
587            }))
588            .await
589            .unwrap();
590        assert!(approved.success, "{:?}", approved.error);
591    }
592
593    #[tokio::test]
594    async fn accepts_schedule_passed_as_json_string() {
595        let tmp = TempDir::new().unwrap();
596        let cfg = test_config(&tmp).await;
597        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
598
599        // Simulate the LLM double-serializing the schedule: the value arrives
600        // as a JSON string containing a JSON object, rather than an object.
601        let result = tool
602            .execute(json!({
603                "schedule": r#"{"kind":"cron","expr":"*/5 * * * *"}"#,
604                "job_type": "shell",
605                "command": "echo string-schedule"
606            }))
607            .await
608            .unwrap();
609
610        assert!(result.success, "{:?}", result.error);
611        assert!(result.output.contains("next_run"));
612    }
613
614    #[tokio::test]
615    async fn accepts_stringified_interval_schedule() {
616        let tmp = TempDir::new().unwrap();
617        let cfg = test_config(&tmp).await;
618        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
619
620        let result = tool
621            .execute(json!({
622                "schedule": r#"{"kind":"every","every_ms":60000}"#,
623                "job_type": "shell",
624                "command": "echo interval"
625            }))
626            .await
627            .unwrap();
628
629        assert!(result.success, "{:?}", result.error);
630    }
631
632    #[tokio::test]
633    async fn accepts_stringified_schedule_with_timezone() {
634        let tmp = TempDir::new().unwrap();
635        let cfg = test_config(&tmp).await;
636        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
637
638        let result = tool
639            .execute(json!({
640                "schedule": r#"{"kind":"cron","expr":"*/30 9-15 * * 1-5","tz":"Asia/Shanghai"}"#,
641                "job_type": "shell",
642                "command": "echo tz-test"
643            }))
644            .await
645            .unwrap();
646
647        assert!(result.success, "{:?}", result.error);
648    }
649
650    #[tokio::test]
651    async fn rejects_invalid_schedule() {
652        let tmp = TempDir::new().unwrap();
653        let cfg = test_config(&tmp).await;
654        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
655
656        let result = tool
657            .execute(json!({
658                "schedule": { "kind": "every", "every_ms": 0 },
659                "job_type": "shell",
660                "command": "echo nope"
661            }))
662            .await
663            .unwrap();
664
665        assert!(!result.success);
666        assert!(
667            result
668                .error
669                .unwrap_or_default()
670                .contains("every_ms must be > 0")
671        );
672    }
673
674    #[tokio::test]
675    async fn agent_job_requires_prompt() {
676        let tmp = TempDir::new().unwrap();
677        let cfg = test_config(&tmp).await;
678        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
679
680        let result = tool
681            .execute(json!({
682                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
683                "job_type": "agent"
684            }))
685            .await
686            .unwrap();
687        assert!(!result.success);
688        assert!(
689            result
690                .error
691                .unwrap_or_default()
692                .contains("Missing 'prompt'")
693        );
694    }
695
696    #[tokio::test]
697    async fn agent_job_persists_allowed_tools() {
698        let tmp = TempDir::new().unwrap();
699        let cfg = test_config(&tmp).await;
700        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
701
702        let result = tool
703            .execute(json!({
704                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
705                "job_type": "agent",
706                "prompt": "check status",
707                "allowed_tools": ["file_read", "web_search"]
708            }))
709            .await
710            .unwrap();
711
712        assert!(result.success, "{:?}", result.error);
713
714        let jobs = cron::list_jobs(&cfg).unwrap();
715        assert_eq!(jobs.len(), 1);
716        assert_eq!(
717            jobs[0].allowed_tools,
718            Some(vec!["file_read".into(), "web_search".into()])
719        );
720    }
721
722    #[tokio::test]
723    async fn empty_allowed_tools_stored_as_none() {
724        let tmp = TempDir::new().unwrap();
725        let cfg = test_config(&tmp).await;
726        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
727
728        let result = tool
729            .execute(json!({
730                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
731                "job_type": "agent",
732                "prompt": "check status",
733                "allowed_tools": []
734            }))
735            .await
736            .unwrap();
737
738        assert!(result.success, "{:?}", result.error);
739
740        let jobs = cron::list_jobs(&cfg).unwrap();
741        assert_eq!(jobs.len(), 1);
742        assert_eq!(
743            jobs[0].allowed_tools, None,
744            "empty allowed_tools should be stored as None"
745        );
746    }
747
748    #[tokio::test]
749    async fn delivery_schema_includes_matrix_channel() {
750        let tmp = TempDir::new().unwrap();
751        let cfg = test_config(&tmp).await;
752        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
753
754        let values =
755            tool.parameters_schema()["properties"]["delivery"]["properties"]["channel"]["enum"]
756                .as_array()
757                .cloned()
758                .unwrap_or_default();
759
760        assert!(values.iter().any(|value| value == "matrix"));
761    }
762
763    #[test]
764    fn schedule_schema_is_oneof_with_cron_at_every_variants() {
765        let tmp = tempfile::TempDir::new().unwrap();
766        let cfg = Arc::new(Config {
767            workspace_dir: tmp.path().join("workspace"),
768            config_path: tmp.path().join("config.toml"),
769            ..Config::default()
770        });
771        let security = Arc::new(SecurityPolicy::from_config(
772            &cfg.autonomy,
773            &cfg.workspace_dir,
774        ));
775        let tool = CronAddTool::new(cfg, security);
776        let schema = tool.parameters_schema();
777
778        // Top-level: schedule is required
779        let top_required = schema["required"].as_array().expect("top-level required");
780        assert!(top_required.iter().any(|v| v == "schedule"));
781
782        // schedule is a oneOf with exactly 3 variants: cron, at, every
783        let one_of = schema["properties"]["schedule"]["oneOf"]
784            .as_array()
785            .expect("schedule.oneOf must be an array");
786        assert_eq!(one_of.len(), 3, "expected cron, at, and every variants");
787
788        let kinds: Vec<&str> = one_of
789            .iter()
790            .filter_map(|v| v["properties"]["kind"]["enum"][0].as_str())
791            .collect();
792        assert!(kinds.contains(&"cron"), "missing cron variant");
793        assert!(kinds.contains(&"at"), "missing at variant");
794        assert!(kinds.contains(&"every"), "missing every variant");
795
796        // Each variant declares its required fields and every_ms is typed integer
797        for variant in one_of {
798            let kind = variant["properties"]["kind"]["enum"][0]
799                .as_str()
800                .expect("variant kind");
801            let req: Vec<&str> = variant["required"]
802                .as_array()
803                .unwrap_or_else(|| panic!("{kind} variant must have required"))
804                .iter()
805                .filter_map(|v| v.as_str())
806                .collect();
807            assert!(
808                req.contains(&"kind"),
809                "{kind} variant missing 'kind' in required"
810            );
811            match kind {
812                "cron" => assert!(req.contains(&"expr"), "cron variant missing 'expr'"),
813                "at" => assert!(req.contains(&"at"), "at variant missing 'at'"),
814                "every" => {
815                    assert!(
816                        req.contains(&"every_ms"),
817                        "every variant missing 'every_ms'"
818                    );
819                    assert_eq!(
820                        variant["properties"]["every_ms"]["type"].as_str(),
821                        Some("integer"),
822                        "every_ms must be typed as integer"
823                    );
824                }
825                _ => panic!("unexpected kind: {kind}"),
826            }
827        }
828    }
829}