Skip to main content

zeroclaw_runtime/tools/
cron_add.rs

1use super::cron_common::{
2    AT_DESCRIPTION, CRON_TZ_DESCRIPTION, cron_add_output, deserialize_schedule_arg,
3};
4use crate::cron::{self, DeliveryConfig, JobType, Schedule, SessionTarget};
5use crate::security::SecurityPolicy;
6use async_trait::async_trait;
7use chrono::{Duration as ChronoDuration, Utc};
8use serde_json::{Value, json};
9use std::sync::Arc;
10use zeroclaw_api::tool::{Tool, ToolResult};
11use zeroclaw_config::schema::Config;
12
13pub struct CronAddTool {
14    config: Arc<Config>,
15    security: Arc<SecurityPolicy>,
16    /// Owning agent — the alias of the agent whose tool loop registered
17    /// this tool instance. Cron jobs created here are validated against
18    /// this agent's risk profile and run as this agent.
19    agent_alias: String,
20}
21
22impl CronAddTool {
23    pub fn new(
24        config: Arc<Config>,
25        security: Arc<SecurityPolicy>,
26        agent_alias: impl Into<String>,
27    ) -> Self {
28        Self {
29            config,
30            security,
31            agent_alias: agent_alias.into(),
32        }
33    }
34
35    fn plain_string_schedule_error(raw: &str) -> Option<String> {
36        let schedule = raw.trim();
37        if schedule.starts_with('{') {
38            return None;
39        }
40
41        let got = serde_json::to_string(schedule).unwrap_or_else(|_| "\"<invalid>\"".to_string());
42        Some(format!(
43            "Invalid schedule: expected a JSON object with a \"kind\" field, got plain string {got}. \
44             Use one of: {{\"kind\":\"cron\",\"expr\":\"0 9 * * 1-5\"}}, \
45             {{\"kind\":\"at\",\"at\":\"2025-12-31T23:59:00Z\"}}, \
46             {{\"kind\":\"after\",\"after_seconds\":600}} for one-shot relative reminders, or \
47             {{\"kind\":\"every\",\"every_ms\":3600000}}"
48        ))
49    }
50
51    fn deserialize_cron_add_schedule_arg(value: &Value) -> Result<CronAddScheduleArg, String> {
52        if let Some(normalized) = normalize_maybe_stringified_schedule_arg(value)?
53            && normalized.get("kind").and_then(Value::as_str) == Some("after")
54        {
55            return CronAddScheduleArg::after_from_value(&normalized);
56        }
57
58        deserialize_schedule_arg(value).map(CronAddScheduleArg::Schedule)
59    }
60
61    fn enforce_mutation_allowed(&self, action: &str) -> Option<ToolResult> {
62        if !self.security.can_act() {
63            return Some(ToolResult {
64                success: false,
65                output: String::new(),
66                error: Some(format!(
67                    "Security policy: read-only mode, cannot perform '{action}'"
68                )),
69            });
70        }
71
72        if self.security.is_rate_limited() {
73            return Some(ToolResult {
74                success: false,
75                output: String::new(),
76                error: Some("Rate limit exceeded: too many actions in the last hour".to_string()),
77            });
78        }
79
80        if !self.security.record_action() {
81            return Some(ToolResult {
82                success: false,
83                output: String::new(),
84                error: Some("Rate limit exceeded: action budget exhausted".to_string()),
85            });
86        }
87
88        None
89    }
90}
91
92fn normalize_maybe_stringified_schedule_arg(value: &Value) -> Result<Option<Value>, String> {
93    match value {
94        Value::String(raw) => {
95            let trimmed = raw.trim();
96            if trimmed.starts_with('{') {
97                serde_json::from_str(trimmed)
98                    .map(Some)
99                    .map_err(|err| format!("Invalid schedule: {err}"))
100            } else {
101                Ok(None)
102            }
103        }
104        other => Ok(Some(other.clone())),
105    }
106}
107
108enum CronAddScheduleArg {
109    Schedule(Schedule),
110    AfterSeconds(u64),
111}
112
113impl CronAddScheduleArg {
114    fn after_from_value(value: &Value) -> Result<Self, String> {
115        let after_seconds = value
116            .get("after_seconds")
117            .and_then(Value::as_u64)
118            .ok_or_else(|| "Invalid schedule: after_seconds must be an integer > 0".to_string())?;
119        if after_seconds == 0 {
120            return Err("Invalid schedule: after_seconds must be > 0".to_string());
121        }
122
123        Ok(Self::AfterSeconds(after_seconds))
124    }
125
126    fn default_delete_after_run(&self) -> bool {
127        matches!(
128            self,
129            Self::Schedule(Schedule::At { .. }) | Self::AfterSeconds(_)
130        )
131    }
132
133    fn into_schedule(self) -> Result<Schedule, String> {
134        match self {
135            Self::Schedule(schedule) => Ok(schedule),
136            Self::AfterSeconds(after_seconds) => {
137                let after_seconds = i64::try_from(after_seconds)
138                    .map_err(|_| "Invalid schedule: after_seconds is too large")?;
139                let delay = ChronoDuration::seconds(after_seconds);
140                let at = Utc::now().checked_add_signed(delay).ok_or_else(|| {
141                    "Invalid schedule: after_seconds overflowed DateTime arithmetic".to_string()
142                })?;
143                Ok(Schedule::At { at })
144            }
145        }
146    }
147}
148
149fn schedule_error_result(error: String) -> ToolResult {
150    ToolResult {
151        success: false,
152        output: String::new(),
153        error: Some(error),
154    }
155}
156
157#[async_trait]
158impl Tool for CronAddTool {
159    fn name(&self) -> &str {
160        "cron_add"
161    }
162
163    fn description(&self) -> &str {
164        "Create a scheduled cron job (shell or agent) with cron/at/after/every schedules. \
165         Use job_type='agent' with a prompt to run the AI agent on schedule. \
166         For relative one-shot reminders such as 'in 10 minutes' or 'after 2 hours', \
167         use schedule={\"kind\":\"after\",\"after_seconds\":...}; the runtime resolves it \
168         with the live clock when the tool executes. \
169         To deliver output to a configured channel, set \
170         delivery={\"mode\":\"announce\",\"channel\":\"discord\",\"to\":\"<channel_id_or_chat_id>\"}. \
171         For webhook deliveries that must thread through the originating conversation, also set \
172         delivery.thread_id=\"<reply_target>\". \
173         This is the preferred tool for sending scheduled/delayed messages to users via channels."
174    }
175
176    fn parameters_schema(&self) -> serde_json::Value {
177        json!({
178            "type": "object",
179            "properties": {
180                "name": {
181                    "type": "string",
182                    "description": "Optional human-readable name for the job"
183                },
184                // NOTE: oneOf is correct for OpenAI-compatible APIs (including OpenRouter).
185                // Gemini does not support oneOf in tool schemas; if Gemini native tool calling
186                // is ever wired up, SchemaCleanr::clean_for_gemini must be applied before
187                // tool specs are sent. See src/tools/schema.rs.
188                "schedule": {
189                    "description": "When to run the job. Exactly one of four forms must be used. Prefer 'after' for relative one-shot reminders.",
190                    "oneOf": [
191                        {
192                            "type": "object",
193                            "description": "Cron expression schedule (repeating). Example: {\"kind\":\"cron\",\"expr\":\"0 9 * * 1-5\",\"tz\":\"America/New_York\"}",
194                            "properties": {
195                                "kind": { "type": "string", "enum": ["cron"] },
196                                "expr": { "type": "string", "description": "Standard 5-field cron expression, e.g. '*/5 * * * *'" },
197                                "tz": { "type": "string", "description": CRON_TZ_DESCRIPTION }
198                            },
199                            "required": ["kind", "expr"]
200                        },
201                        {
202                            "type": "object",
203                            "description": "One-shot schedule at a specific RFC3339 timestamp with explicit Z or offset. Example: {\"kind\":\"at\",\"at\":\"2025-12-31T23:59:00Z\"}",
204                            "properties": {
205                                "kind": { "type": "string", "enum": ["at"] },
206                                "at": { "type": "string", "description": AT_DESCRIPTION }
207                            },
208                            "required": ["kind", "at"]
209                        },
210                        {
211                            "type": "object",
212                            "description": "One-shot relative delay in seconds. Prefer this for reminders like 'in 10 minutes' so the runtime resolves the live clock. Example: {\"kind\":\"after\",\"after_seconds\":600}",
213                            "properties": {
214                                "kind": { "type": "string", "enum": ["after"] },
215                                "after_seconds": { "type": "integer", "minimum": 1, "description": "Delay from job creation time in seconds, e.g. 600 for 10 minutes" }
216                            },
217                            "required": ["kind", "after_seconds"]
218                        },
219                        {
220                            "type": "object",
221                            "description": "Repeating interval schedule in milliseconds. Example: {\"kind\":\"every\",\"every_ms\":3600000} runs every hour.",
222                            "properties": {
223                                "kind": { "type": "string", "enum": ["every"] },
224                                "every_ms": { "type": "integer", "description": "Interval in milliseconds, e.g. 3600000 for every hour" }
225                            },
226                            "required": ["kind", "every_ms"]
227                        }
228                    ]
229                },
230                "job_type": {
231                    "type": "string",
232                    "enum": ["shell", "agent"],
233                    "description": "Type of job: 'shell' runs a command, 'agent' runs the AI agent with a prompt"
234                },
235                "command": {
236                    "type": "string",
237                    "description": "Shell command to run (required when job_type is 'shell')"
238                },
239                "prompt": {
240                    "type": "string",
241                    "description": "Agent prompt to run on schedule (required when job_type is 'agent')"
242                },
243                "session_target": {
244                    "type": "string",
245                    "enum": ["isolated", "main"],
246                    "description": "Agent session context: 'isolated' starts a fresh session each run, 'main' reuses the primary session"
247                },
248                "model": {
249                    "type": "string",
250                    "description": "Optional model override for agent jobs, e.g. 'x-ai/grok-4-1-fast'"
251                },
252                "allowed_tools": {
253                    "type": "array",
254                    "items": { "type": "string" },
255                    "description": "Optional allowlist of tool names for agent jobs. When omitted, cron-launched agent runs keep non-scheduler tools available but exclude scheduler mutation tools such as cron_add, cron_update, cron_remove, cron_run, and schedule. Include those names explicitly to opt back in."
256                },
257                "delivery": {
258                    "type": "object",
259                    "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.",
260                    "properties": {
261                        "mode": {
262                            "type": "string",
263                            "enum": ["none", "announce"],
264                            "description": "'announce' sends output to the specified channel; 'none' disables delivery"
265                        },
266                        "channel": {
267                            "type": "string",
268                            "enum": cron::CRON_DELIVERY_SCHEMA_CHANNELS,
269                            "description": "Channel type to deliver output to"
270                        },
271                        "to": {
272                            "type": "string",
273                            "description": "Destination ID: Discord channel ID, Telegram chat ID, Slack channel name, webhook recipient, etc."
274                        },
275                        "thread_id": {
276                            "type": "string",
277                            "description": "Optional thread/conversation identifier. Used by the webhook channel to route callbacks to the originating conversation; ignored by channels whose threading is implied by `to`."
278                        },
279                        "best_effort": {
280                            "type": "boolean",
281                            "description": "If true, a delivery failure does not fail the job itself. Defaults to true."
282                        }
283                    }
284                },
285                "delete_after_run": {
286                    "type": "boolean",
287                    "description": "If true, the job is automatically deleted after its first successful run. Defaults to true for one-shot 'at' and 'after' schedules."
288                },
289                "approved": {
290                    "type": "boolean",
291                    "description": "Set true to explicitly approve medium/high-risk shell commands in supervised mode",
292                    "default": false
293                }
294            },
295            "required": ["schedule"]
296        })
297    }
298
299    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
300        if !self.config.scheduler.enabled {
301            return Ok(ToolResult {
302                success: false,
303                output: String::new(),
304                error: Some("cron is disabled by config (scheduler.enabled=false)".to_string()),
305            });
306        }
307
308        let schedule_arg = match args.get("schedule") {
309            Some(v @ serde_json::Value::String(raw)) => {
310                if let Some(error) = Self::plain_string_schedule_error(raw) {
311                    return Ok(ToolResult {
312                        success: false,
313                        output: String::new(),
314                        error: Some(error),
315                    });
316                }
317
318                match Self::deserialize_cron_add_schedule_arg(v) {
319                    Ok(schedule) => schedule,
320                    Err(error) => {
321                        return Ok(ToolResult {
322                            success: false,
323                            output: String::new(),
324                            error: Some(error),
325                        });
326                    }
327                }
328            }
329            Some(v) => match Self::deserialize_cron_add_schedule_arg(v) {
330                Ok(schedule) => schedule,
331                Err(error) => {
332                    return Ok(ToolResult {
333                        success: false,
334                        output: String::new(),
335                        error: Some(error),
336                    });
337                }
338            },
339            None => {
340                return Ok(ToolResult {
341                    success: false,
342                    output: String::new(),
343                    error: Some("Missing 'schedule' parameter".to_string()),
344                });
345            }
346        };
347
348        let name = args
349            .get("name")
350            .and_then(serde_json::Value::as_str)
351            .map(str::to_string);
352
353        let job_type = match args.get("job_type").and_then(serde_json::Value::as_str) {
354            Some("agent") => JobType::Agent,
355            Some("shell") => JobType::Shell,
356            Some(other) => {
357                return Ok(ToolResult {
358                    success: false,
359                    output: String::new(),
360                    error: Some(format!("Invalid job_type: {other}")),
361                });
362            }
363            None => {
364                if args.get("prompt").is_some() {
365                    JobType::Agent
366                } else {
367                    JobType::Shell
368                }
369            }
370        };
371
372        let default_delete_after_run = schedule_arg.default_delete_after_run();
373        let delete_after_run = args
374            .get("delete_after_run")
375            .and_then(serde_json::Value::as_bool)
376            .unwrap_or(default_delete_after_run);
377        let approved = args
378            .get("approved")
379            .and_then(serde_json::Value::as_bool)
380            .unwrap_or(false);
381        let delivery = match args.get("delivery") {
382            Some(v) => match serde_json::from_value::<DeliveryConfig>(v.clone()) {
383                Ok(cfg) => Some(cfg),
384                Err(e) => {
385                    return Ok(ToolResult {
386                        success: false,
387                        output: String::new(),
388                        error: Some(format!("Invalid delivery config: {e}")),
389                    });
390                }
391            },
392            None => None,
393        };
394
395        let result = match job_type {
396            JobType::Shell => {
397                let command = match args.get("command").and_then(serde_json::Value::as_str) {
398                    Some(command) if !command.trim().is_empty() => command,
399                    _ => {
400                        return Ok(ToolResult {
401                            success: false,
402                            output: String::new(),
403                            error: Some("Missing 'command' for shell job".to_string()),
404                        });
405                    }
406                };
407
408                if let Err(reason) = self.security.validate_command_execution(command, approved) {
409                    return Ok(ToolResult {
410                        success: false,
411                        output: String::new(),
412                        error: Some(reason),
413                    });
414                }
415
416                if let Some(blocked) = self.enforce_mutation_allowed("cron_add") {
417                    return Ok(blocked);
418                }
419
420                let schedule = match schedule_arg.into_schedule() {
421                    Ok(schedule) => schedule,
422                    Err(error) => return Ok(schedule_error_result(error)),
423                };
424
425                cron::add_shell_job_with_approval(
426                    &self.config,
427                    &self.agent_alias,
428                    name,
429                    schedule,
430                    command,
431                    delivery,
432                    approved,
433                )
434            }
435            JobType::Agent => {
436                let prompt = match args.get("prompt").and_then(serde_json::Value::as_str) {
437                    Some(prompt) if !prompt.trim().is_empty() => prompt,
438                    _ => {
439                        return Ok(ToolResult {
440                            success: false,
441                            output: String::new(),
442                            error: Some("Missing 'prompt' for agent job".to_string()),
443                        });
444                    }
445                };
446
447                let session_target = match args.get("session_target") {
448                    Some(v) => match serde_json::from_value::<SessionTarget>(v.clone()) {
449                        Ok(target) => target,
450                        Err(e) => {
451                            return Ok(ToolResult {
452                                success: false,
453                                output: String::new(),
454                                error: Some(format!("Invalid session_target: {e}")),
455                            });
456                        }
457                    },
458                    None => SessionTarget::Isolated,
459                };
460
461                let model = args
462                    .get("model")
463                    .and_then(serde_json::Value::as_str)
464                    .map(str::to_string);
465                let allowed_tools = match args.get("allowed_tools") {
466                    Some(v) => match serde_json::from_value::<Vec<String>>(v.clone()) {
467                        Ok(v) => {
468                            if v.is_empty() {
469                                None // Treat empty list same as unset
470                            } else {
471                                Some(v)
472                            }
473                        }
474                        Err(e) => {
475                            return Ok(ToolResult {
476                                success: false,
477                                output: String::new(),
478                                error: Some(format!("Invalid allowed_tools: {e}")),
479                            });
480                        }
481                    },
482                    None => None,
483                };
484
485                if let Some(blocked) = self.enforce_mutation_allowed("cron_add") {
486                    return Ok(blocked);
487                }
488
489                let schedule = match schedule_arg.into_schedule() {
490                    Ok(schedule) => schedule,
491                    Err(error) => return Ok(schedule_error_result(error)),
492                };
493
494                cron::add_agent_job(
495                    &self.config,
496                    &self.agent_alias,
497                    name,
498                    schedule,
499                    prompt,
500                    session_target,
501                    model,
502                    delivery,
503                    delete_after_run,
504                    allowed_tools,
505                )
506            }
507        };
508
509        match result {
510            Ok(job) => Ok(ToolResult {
511                success: true,
512                output: serde_json::to_string_pretty(&cron_add_output(&job))?,
513                error: None,
514            }),
515            Err(e) => Ok(ToolResult {
516                success: false,
517                output: String::new(),
518                error: Some(e.to_string()),
519            }),
520        }
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use crate::security::AutonomyLevel;
528    use tempfile::TempDir;
529    use zeroclaw_config::schema::Config;
530
531    const TEST_AGENT: &str = "test-agent";
532
533    async fn test_config(tmp: &TempDir) -> Arc<Config> {
534        let mut config = Config {
535            data_dir: tmp.path().join("data"),
536            config_path: tmp.path().join("config.toml"),
537            ..Config::default()
538        };
539        seed_test_agent(&mut config);
540        tokio::fs::create_dir_all(&config.data_dir).await.unwrap();
541        Arc::new(config)
542    }
543
544    fn test_security(cfg: &Config) -> Arc<SecurityPolicy> {
545        Arc::new(
546            SecurityPolicy::for_agent(cfg, TEST_AGENT).expect("test-agent has resolvable profiles"),
547        )
548    }
549
550    fn seed_test_agent(config: &mut Config) {
551        config
552            .risk_profiles
553            .entry(TEST_AGENT.to_string())
554            .or_default();
555        config
556            .runtime_profiles
557            .entry(TEST_AGENT.to_string())
558            .or_default();
559        config
560            .providers
561            .models
562            .ensure("openrouter", TEST_AGENT)
563            .expect("known family");
564        config.agents.entry(TEST_AGENT.to_string()).or_insert(
565            zeroclaw_config::schema::AliasedAgentConfig {
566                model_provider: format!("openrouter.{TEST_AGENT}").into(),
567                risk_profile: TEST_AGENT.to_string(),
568                runtime_profile: TEST_AGENT.to_string(),
569                ..Default::default()
570            },
571        );
572    }
573
574    #[tokio::test]
575    async fn adds_shell_job() {
576        let tmp = TempDir::new().unwrap();
577        let cfg = test_config(&tmp).await;
578        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
579        let result = tool
580            .execute(json!({
581                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
582                "job_type": "shell",
583                "command": "echo ok"
584            }))
585            .await
586            .unwrap();
587
588        assert!(result.success, "{:?}", result.error);
589        assert!(result.output.contains("next_run"));
590    }
591
592    #[tokio::test]
593    async fn output_includes_timezone_confirmation_fields_for_explicit_cron_timezone() {
594        let tmp = TempDir::new().unwrap();
595        let cfg = test_config(&tmp).await;
596        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
597        let result = tool
598            .execute(json!({
599                "schedule": { "kind": "cron", "expr": "0 9 * * 1-5", "tz": "America/New_York" },
600                "job_type": "shell",
601                "command": "echo ok"
602            }))
603            .await
604            .unwrap();
605
606        assert!(result.success, "{:?}", result.error);
607        let output: serde_json::Value = serde_json::from_str(&result.output).unwrap();
608        assert_eq!(output["next_run"], output["next_run_utc"]);
609        assert_eq!(output["schedule_timezone"], "America/New_York");
610        assert_eq!(output["timezone_source"], "explicit");
611        assert!(
612            output["next_run_local"]
613                .as_str()
614                .is_some_and(|value| value.contains("T09:00:00")),
615            "next_run_local should display the next run in the explicit schedule timezone: {output}"
616        );
617    }
618
619    #[tokio::test]
620    async fn output_identifies_runtime_local_fallback_when_cron_timezone_is_omitted() {
621        let tmp = TempDir::new().unwrap();
622        let cfg = test_config(&tmp).await;
623        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
624        let result = tool
625            .execute(json!({
626                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
627                "job_type": "shell",
628                "command": "echo ok"
629            }))
630            .await
631            .unwrap();
632
633        assert!(result.success, "{:?}", result.error);
634        let output: serde_json::Value = serde_json::from_str(&result.output).unwrap();
635        assert_eq!(output["timezone_source"], "runtime_local");
636        assert_eq!(output["schedule_timezone"], "runtime local timezone");
637        assert!(
638            output["next_run_local"].as_str().is_some(),
639            "next_run_local should be present for runtime-local cron schedules: {output}"
640        );
641    }
642
643    #[tokio::test]
644    async fn shell_job_persists_delivery() {
645        let tmp = TempDir::new().unwrap();
646        let cfg = test_config(&tmp).await;
647        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
648        let result = tool
649            .execute(json!({
650                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
651                "job_type": "shell",
652                "command": "echo ok",
653                "delivery": {
654                    "mode": "announce",
655                    "channel": "discord",
656                    "to": "1234567890",
657                    "best_effort": true
658                }
659            }))
660            .await
661            .unwrap();
662
663        assert!(result.success, "{:?}", result.error);
664
665        let jobs = cron::list_jobs(&cfg).unwrap();
666        assert_eq!(jobs.len(), 1);
667        assert_eq!(jobs[0].delivery.mode, "announce");
668        assert_eq!(jobs[0].delivery.channel.as_deref(), Some("discord"));
669        assert_eq!(jobs[0].delivery.to.as_deref(), Some("1234567890"));
670        assert!(jobs[0].delivery.best_effort);
671    }
672
673    #[tokio::test]
674    async fn blocks_disallowed_shell_command() {
675        let tmp = TempDir::new().unwrap();
676        let mut config = Config {
677            data_dir: tmp.path().join("data"),
678            config_path: tmp.path().join("config.toml"),
679            ..Config::default()
680        };
681        seed_test_agent(&mut config);
682        config
683            .risk_profiles
684            .entry(TEST_AGENT.into())
685            .or_default()
686            .allowed_commands = vec!["echo".into()];
687        config
688            .risk_profiles
689            .entry(TEST_AGENT.into())
690            .or_default()
691            .level = AutonomyLevel::Supervised;
692        tokio::fs::create_dir_all(&config.data_dir).await.unwrap();
693        let cfg = Arc::new(config);
694        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
695
696        let result = tool
697            .execute(json!({
698                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
699                "job_type": "shell",
700                "command": "curl https://example.com"
701            }))
702            .await
703            .unwrap();
704
705        assert!(!result.success);
706        assert!(result.error.unwrap_or_default().contains("not allowed"));
707    }
708
709    #[tokio::test]
710    async fn blocks_mutation_in_read_only_mode() {
711        let tmp = TempDir::new().unwrap();
712        let mut config = Config {
713            data_dir: tmp.path().join("data"),
714            config_path: tmp.path().join("config.toml"),
715            ..Config::default()
716        };
717        seed_test_agent(&mut config);
718        config
719            .risk_profiles
720            .entry(TEST_AGENT.into())
721            .or_default()
722            .level = AutonomyLevel::ReadOnly;
723        std::fs::create_dir_all(&config.data_dir).unwrap();
724        let cfg = Arc::new(config);
725        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
726
727        let result = tool
728            .execute(json!({
729                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
730                "job_type": "shell",
731                "command": "echo ok"
732            }))
733            .await
734            .unwrap();
735
736        assert!(!result.success);
737        let error = result.error.unwrap_or_default();
738        assert!(error.contains("read-only") || error.contains("not allowed"));
739    }
740
741    #[tokio::test]
742    async fn blocks_add_when_rate_limited() {
743        let tmp = TempDir::new().unwrap();
744        let mut config = Config {
745            data_dir: tmp.path().join("data"),
746            config_path: tmp.path().join("config.toml"),
747            ..Config::default()
748        };
749        seed_test_agent(&mut config);
750        config
751            .risk_profiles
752            .entry(TEST_AGENT.into())
753            .or_default()
754            .level = AutonomyLevel::Full;
755        config
756            .runtime_profiles
757            .entry(TEST_AGENT.into())
758            .or_default()
759            .max_actions_per_hour = 0;
760        std::fs::create_dir_all(&config.data_dir).unwrap();
761        let cfg = Arc::new(config);
762        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
763
764        let result = tool
765            .execute(json!({
766                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
767                "job_type": "shell",
768                "command": "echo ok"
769            }))
770            .await
771            .unwrap();
772
773        assert!(!result.success);
774        assert!(
775            result
776                .error
777                .unwrap_or_default()
778                .contains("Rate limit exceeded")
779        );
780        assert!(cron::list_jobs(&cfg).unwrap().is_empty());
781    }
782
783    #[tokio::test]
784    async fn medium_risk_shell_command_requires_approval() {
785        let tmp = TempDir::new().unwrap();
786        let mut config = Config {
787            data_dir: tmp.path().join("data"),
788            config_path: tmp.path().join("config.toml"),
789            ..Config::default()
790        };
791        seed_test_agent(&mut config);
792        config
793            .risk_profiles
794            .entry(TEST_AGENT.into())
795            .or_default()
796            .allowed_commands = vec!["touch".into()];
797        config
798            .risk_profiles
799            .entry(TEST_AGENT.into())
800            .or_default()
801            .level = AutonomyLevel::Supervised;
802        std::fs::create_dir_all(&config.data_dir).unwrap();
803        let cfg = Arc::new(config);
804        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
805
806        let denied = tool
807            .execute(json!({
808                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
809                "job_type": "shell",
810                "command": "touch cron-approval-test"
811            }))
812            .await
813            .unwrap();
814        assert!(!denied.success);
815        assert!(
816            denied
817                .error
818                .unwrap_or_default()
819                .contains("explicit approval")
820        );
821
822        let approved = tool
823            .execute(json!({
824                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
825                "job_type": "shell",
826                "command": "touch cron-approval-test",
827                "approved": true
828            }))
829            .await
830            .unwrap();
831        assert!(approved.success, "{:?}", approved.error);
832    }
833
834    #[tokio::test]
835    async fn accepts_schedule_passed_as_json_string() {
836        let tmp = TempDir::new().unwrap();
837        let cfg = test_config(&tmp).await;
838        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
839
840        // Simulate the LLM double-serializing the schedule: the value arrives
841        // as a JSON string containing a JSON object, rather than an object.
842        let result = tool
843            .execute(json!({
844                "schedule": r#"{"kind":"cron","expr":"*/5 * * * *"}"#,
845                "job_type": "shell",
846                "command": "echo string-schedule"
847            }))
848            .await
849            .unwrap();
850
851        assert!(result.success, "{:?}", result.error);
852        assert!(result.output.contains("next_run"));
853    }
854
855    #[tokio::test]
856    async fn rejects_plain_string_schedule_with_actionable_error() {
857        let tmp = TempDir::new().unwrap();
858        let cfg = test_config(&tmp).await;
859        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
860
861        let result = tool
862            .execute(json!({
863                "schedule": "0 9 * * 1-5",
864                "job_type": "shell",
865                "command": "echo bad-schedule"
866            }))
867            .await
868            .unwrap();
869
870        assert!(!result.success);
871        let error = result.error.unwrap_or_default();
872        assert!(error.contains("expected a JSON object"));
873        assert!(error.contains("\"kind\""));
874        assert!(error.contains("plain string \"0 9 * * 1-5\""));
875        assert!(error.contains("{\"kind\":\"cron\",\"expr\":\"0 9 * * 1-5\"}"));
876        assert!(error.contains("{\"kind\":\"at\",\"at\":\"2025-12-31T23:59:00Z\"}"));
877        assert!(error.contains("{\"kind\":\"every\",\"every_ms\":3600000}"));
878        assert!(!error.contains("internally tagged enum"));
879    }
880
881    #[tokio::test]
882    async fn accepts_stringified_interval_schedule() {
883        let tmp = TempDir::new().unwrap();
884        let cfg = test_config(&tmp).await;
885        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
886
887        let result = tool
888            .execute(json!({
889                "schedule": r#"{"kind":"every","every_ms":60000}"#,
890                "job_type": "shell",
891                "command": "echo interval"
892            }))
893            .await
894            .unwrap();
895
896        assert!(result.success, "{:?}", result.error);
897    }
898
899    #[tokio::test]
900    async fn accepts_relative_after_schedule_as_one_shot_at() {
901        let tmp = TempDir::new().unwrap();
902        let cfg = test_config(&tmp).await;
903        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
904
905        let before = chrono::Utc::now();
906        let result = tool
907            .execute(json!({
908                "schedule": { "kind": "after", "after_seconds": 60 },
909                "job_type": "agent",
910                "prompt": "remind me to drink water"
911            }))
912            .await
913            .unwrap();
914        let after = chrono::Utc::now();
915
916        assert!(result.success, "{:?}", result.error);
917        let jobs = cron::list_jobs(&cfg).unwrap();
918        assert_eq!(jobs.len(), 1);
919        match jobs[0].schedule {
920            Schedule::At { at } => {
921                assert!(at >= before + chrono::Duration::seconds(60));
922                assert!(at <= after + chrono::Duration::seconds(60));
923                assert_eq!(jobs[0].next_run, at);
924            }
925            ref other => {
926                panic!("after input should persist as one-shot at schedule, got {other:?}")
927            }
928        }
929        assert!(jobs[0].delete_after_run);
930
931        let schema = tool.parameters_schema();
932        let delete_description = schema["properties"]["delete_after_run"]["description"]
933            .as_str()
934            .unwrap_or_default();
935        assert!(delete_description.contains("'at' and 'after' schedules"));
936    }
937
938    #[tokio::test]
939    async fn accepts_stringified_relative_after_schedule() {
940        let tmp = TempDir::new().unwrap();
941        let cfg = test_config(&tmp).await;
942        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
943
944        let before = chrono::Utc::now();
945        let result = tool
946            .execute(json!({
947                "schedule": r#"{"kind":"after","after_seconds":60}"#,
948                "job_type": "agent",
949                "prompt": "remind me to drink water"
950            }))
951            .await
952            .unwrap();
953        let after = chrono::Utc::now();
954
955        assert!(result.success, "{:?}", result.error);
956        let jobs = cron::list_jobs(&cfg).unwrap();
957        match jobs[0].schedule {
958            Schedule::At { at } => {
959                assert!(at >= before + chrono::Duration::seconds(60));
960                assert!(at <= after + chrono::Duration::seconds(60));
961            }
962            ref other => panic!("after input should persist as an at schedule, got {other:?}"),
963        }
964    }
965
966    #[tokio::test]
967    async fn rejects_after_schedule_with_non_positive_delay() {
968        let tmp = TempDir::new().unwrap();
969        let cfg = test_config(&tmp).await;
970        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
971
972        let result = tool
973            .execute(json!({
974                "schedule": { "kind": "after", "after_seconds": 0 },
975                "job_type": "agent",
976                "prompt": "remind me"
977            }))
978            .await
979            .unwrap();
980
981        assert!(!result.success);
982        assert!(
983            result
984                .error
985                .unwrap_or_default()
986                .contains("after_seconds must be > 0")
987        );
988    }
989
990    #[tokio::test]
991    async fn accepts_stringified_schedule_with_timezone() {
992        let tmp = TempDir::new().unwrap();
993        let cfg = test_config(&tmp).await;
994        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
995
996        let result = tool
997            .execute(json!({
998                "schedule": r#"{"kind":"cron","expr":"*/30 9-15 * * 1-5","tz":"Asia/Shanghai"}"#,
999                "job_type": "shell",
1000                "command": "echo tz-test"
1001            }))
1002            .await
1003            .unwrap();
1004
1005        assert!(result.success, "{:?}", result.error);
1006    }
1007
1008    #[tokio::test]
1009    async fn rejects_invalid_schedule() {
1010        let tmp = TempDir::new().unwrap();
1011        let cfg = test_config(&tmp).await;
1012        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
1013
1014        let result = tool
1015            .execute(json!({
1016                "schedule": { "kind": "every", "every_ms": 0 },
1017                "job_type": "shell",
1018                "command": "echo nope"
1019            }))
1020            .await
1021            .unwrap();
1022
1023        assert!(!result.success);
1024        assert!(
1025            result
1026                .error
1027                .unwrap_or_default()
1028                .contains("every_ms must be > 0")
1029        );
1030    }
1031
1032    #[tokio::test]
1033    async fn rejects_at_timestamp_without_explicit_offset_with_actionable_error() {
1034        let tmp = TempDir::new().unwrap();
1035        let cfg = test_config(&tmp).await;
1036        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
1037
1038        let result = tool
1039            .execute(json!({
1040                "schedule": { "kind": "at", "at": "2026-05-18T09:00:00" },
1041                "job_type": "shell",
1042                "command": "echo at"
1043            }))
1044            .await
1045            .unwrap();
1046
1047        assert!(!result.success);
1048        let error = result.error.unwrap_or_default();
1049        assert!(
1050            error.contains("RFC3339 timestamp with explicit Z or offset"),
1051            "error should explain the explicit offset requirement: {error}"
1052        );
1053        assert!(error.contains("2026-05-18T09:00:00Z"));
1054        assert!(error.contains("2026-05-18T09:00:00-04:00"));
1055    }
1056
1057    #[tokio::test]
1058    async fn agent_job_requires_prompt() {
1059        let tmp = TempDir::new().unwrap();
1060        let cfg = test_config(&tmp).await;
1061        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
1062
1063        let result = tool
1064            .execute(json!({
1065                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
1066                "job_type": "agent"
1067            }))
1068            .await
1069            .unwrap();
1070        assert!(!result.success);
1071        assert!(
1072            result
1073                .error
1074                .unwrap_or_default()
1075                .contains("Missing 'prompt'")
1076        );
1077    }
1078
1079    #[tokio::test]
1080    async fn agent_job_persists_allowed_tools() {
1081        let tmp = TempDir::new().unwrap();
1082        let cfg = test_config(&tmp).await;
1083        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
1084
1085        let result = tool
1086            .execute(json!({
1087                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
1088                "job_type": "agent",
1089                "prompt": "check status",
1090                "allowed_tools": ["file_read", "web_search"]
1091            }))
1092            .await
1093            .unwrap();
1094
1095        assert!(result.success, "{:?}", result.error);
1096
1097        let jobs = cron::list_jobs(&cfg).unwrap();
1098        assert_eq!(jobs.len(), 1);
1099        assert_eq!(
1100            jobs[0].allowed_tools,
1101            Some(vec!["file_read".into(), "web_search".into()])
1102        );
1103    }
1104
1105    #[tokio::test]
1106    async fn empty_allowed_tools_stored_as_none() {
1107        let tmp = TempDir::new().unwrap();
1108        let cfg = test_config(&tmp).await;
1109        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
1110
1111        let result = tool
1112            .execute(json!({
1113                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
1114                "job_type": "agent",
1115                "prompt": "check status",
1116                "allowed_tools": []
1117            }))
1118            .await
1119            .unwrap();
1120
1121        assert!(result.success, "{:?}", result.error);
1122
1123        let jobs = cron::list_jobs(&cfg).unwrap();
1124        assert_eq!(jobs.len(), 1);
1125        assert_eq!(
1126            jobs[0].allowed_tools, None,
1127            "empty allowed_tools should be stored as None"
1128        );
1129    }
1130
1131    #[tokio::test]
1132    async fn allowed_tools_schema_documents_scheduler_mutation_default() {
1133        let tmp = TempDir::new().unwrap();
1134        let cfg = test_config(&tmp).await;
1135        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
1136
1137        let schema = tool.parameters_schema();
1138        let description = schema["properties"]["allowed_tools"]["description"]
1139            .as_str()
1140            .unwrap_or_default();
1141
1142        assert!(description.contains("exclude scheduler mutation tools"));
1143        assert!(description.contains("cron_add"));
1144        assert!(description.contains("opt back in"));
1145    }
1146
1147    #[tokio::test]
1148    async fn delivery_schema_includes_supported_channels() {
1149        let tmp = TempDir::new().unwrap();
1150        let cfg = test_config(&tmp).await;
1151        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
1152
1153        let schema = tool.parameters_schema();
1154        let values: Vec<&str> = schema["properties"]["delivery"]["properties"]["channel"]["enum"]
1155            .as_array()
1156            .expect("delivery.channel must have an enum")
1157            .iter()
1158            .filter_map(|value| value.as_str())
1159            .collect();
1160
1161        assert_eq!(values.as_slice(), cron::CRON_DELIVERY_SCHEMA_CHANNELS);
1162        assert!(values.contains(&"dingtalk"));
1163    }
1164
1165    #[tokio::test]
1166    async fn delivery_schema_includes_webhook_and_thread_id() {
1167        let tmp = TempDir::new().unwrap();
1168        let cfg = test_config(&tmp).await;
1169        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
1170        let schema = tool.parameters_schema();
1171
1172        let channel_enum = schema["properties"]["delivery"]["properties"]["channel"]["enum"]
1173            .as_array()
1174            .cloned()
1175            .unwrap_or_default();
1176        assert!(
1177            channel_enum.iter().any(|value| value == "webhook"),
1178            "delivery.channel enum must include webhook"
1179        );
1180
1181        let delivery_props = schema["properties"]["delivery"]["properties"]
1182            .as_object()
1183            .expect("delivery must have properties");
1184        assert!(
1185            delivery_props.contains_key("thread_id"),
1186            "delivery schema must expose thread_id so the webhook channel can route callbacks"
1187        );
1188    }
1189
1190    #[tokio::test]
1191    async fn webhook_announce_job_persists_thread_id() {
1192        let tmp = TempDir::new().unwrap();
1193        let cfg = test_config(&tmp).await;
1194        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
1195        let result = tool
1196            .execute(json!({
1197                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
1198                "job_type": "shell",
1199                "command": "echo ok",
1200                "delivery": {
1201                    "mode": "announce",
1202                    "channel": "webhook",
1203                    "to": "user-42",
1204                    "thread_id": "conv-99",
1205                    "best_effort": true
1206                }
1207            }))
1208            .await
1209            .unwrap();
1210
1211        assert!(result.success, "{:?}", result.error);
1212
1213        let jobs = cron::list_jobs(&cfg).unwrap();
1214        assert_eq!(jobs.len(), 1);
1215        assert_eq!(jobs[0].delivery.mode, "announce");
1216        assert_eq!(jobs[0].delivery.channel.as_deref(), Some("webhook"));
1217        assert_eq!(jobs[0].delivery.to.as_deref(), Some("user-42"));
1218        assert_eq!(jobs[0].delivery.thread_id.as_deref(), Some("conv-99"));
1219        assert!(jobs[0].delivery.best_effort);
1220    }
1221
1222    #[tokio::test]
1223    async fn past_at_schedule_error_includes_clock_diagnostics() {
1224        let tmp = TempDir::new().unwrap();
1225        let cfg = test_config(&tmp).await;
1226        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
1227
1228        let result = tool
1229            .execute(json!({
1230                "schedule": { "kind": "at", "at": "2020-01-01T00:00:00Z" },
1231                "job_type": "shell",
1232                "command": "echo at"
1233            }))
1234            .await
1235            .unwrap();
1236
1237        assert!(!result.success);
1238        let error = result.error.unwrap_or_default();
1239        assert!(error.contains("'at' must be in the future"));
1240        assert!(error.contains("now_utc="), "{error}");
1241        assert!(error.contains("now_local="), "{error}");
1242        assert!(
1243            error.contains("at_utc=2020-01-01T00:00:00+00:00"),
1244            "{error}"
1245        );
1246        assert!(error.contains("at_local="), "{error}");
1247        assert!(error.contains("delta_seconds="), "{error}");
1248    }
1249
1250    #[test]
1251    fn schedule_schema_is_oneof_with_cron_at_every_variants() {
1252        let tmp = tempfile::TempDir::new().unwrap();
1253        let cfg = Arc::new(Config {
1254            data_dir: tmp.path().join("data"),
1255            config_path: tmp.path().join("config.toml"),
1256            ..Config::default()
1257        });
1258        let security = Arc::new(SecurityPolicy::from_risk_profile(
1259            &zeroclaw_config::schema::RiskProfileConfig::default(),
1260            &cfg.data_dir,
1261        ));
1262        let tool = CronAddTool::new(cfg, security, TEST_AGENT);
1263        let schema = tool.parameters_schema();
1264
1265        // Top-level: schedule is required
1266        let top_required = schema["required"].as_array().expect("top-level required");
1267        assert!(top_required.iter().any(|v| v == "schedule"));
1268
1269        // schedule is a oneOf with four variants: cron, at, after, every
1270        let one_of = schema["properties"]["schedule"]["oneOf"]
1271            .as_array()
1272            .expect("schedule.oneOf must be an array");
1273        assert_eq!(
1274            one_of.len(),
1275            4,
1276            "expected cron, at, after, and every variants"
1277        );
1278
1279        let kinds: Vec<&str> = one_of
1280            .iter()
1281            .filter_map(|v| v["properties"]["kind"]["enum"][0].as_str())
1282            .collect();
1283        assert!(kinds.contains(&"cron"), "missing cron variant");
1284        assert!(kinds.contains(&"at"), "missing at variant");
1285        assert!(kinds.contains(&"after"), "missing after variant");
1286        assert!(kinds.contains(&"every"), "missing every variant");
1287
1288        // Each variant declares its required fields and duration fields are typed integers.
1289        for variant in one_of {
1290            let kind = variant["properties"]["kind"]["enum"][0]
1291                .as_str()
1292                .expect("variant kind");
1293            let req: Vec<&str> = variant["required"]
1294                .as_array()
1295                .unwrap_or_else(|| panic!("{kind} variant must have required"))
1296                .iter()
1297                .filter_map(|v| v.as_str())
1298                .collect();
1299            assert!(
1300                req.contains(&"kind"),
1301                "{kind} variant missing 'kind' in required"
1302            );
1303            match kind {
1304                "cron" => assert!(req.contains(&"expr"), "cron variant missing 'expr'"),
1305                "at" => assert!(req.contains(&"at"), "at variant missing 'at'"),
1306                "after" => {
1307                    assert!(
1308                        req.contains(&"after_seconds"),
1309                        "after variant missing 'after_seconds'"
1310                    );
1311                    assert_eq!(
1312                        variant["properties"]["after_seconds"]["type"].as_str(),
1313                        Some("integer"),
1314                        "after_seconds must be typed as integer"
1315                    );
1316                    assert_eq!(
1317                        variant["properties"]["after_seconds"]["minimum"].as_i64(),
1318                        Some(1),
1319                        "after_seconds must declare a positive minimum"
1320                    );
1321                }
1322                "every" => {
1323                    assert!(
1324                        req.contains(&"every_ms"),
1325                        "every variant missing 'every_ms'"
1326                    );
1327                    assert_eq!(
1328                        variant["properties"]["every_ms"]["type"].as_str(),
1329                        Some("integer"),
1330                        "every_ms must be typed as integer"
1331                    );
1332                }
1333                _ => panic!("unexpected kind: {kind}"),
1334            }
1335        }
1336
1337        let cron_variant = one_of
1338            .iter()
1339            .find(|variant| variant["properties"]["kind"]["enum"][0] == "cron")
1340            .expect("cron variant");
1341        let cron_tz_description = cron_variant["properties"]["tz"]["description"]
1342            .as_str()
1343            .expect("cron tz description");
1344        assert!(
1345            cron_tz_description.contains("runtime local timezone"),
1346            "cron tz description must match scheduler fallback: {cron_tz_description}"
1347        );
1348        assert!(
1349            cron_tz_description.contains("explicit IANA timezone"),
1350            "cron tz description should recommend explicit IANA timezones: {cron_tz_description}"
1351        );
1352        assert!(
1353            !cron_tz_description.contains("Defaults to UTC"),
1354            "cron tz description must not claim a UTC default"
1355        );
1356
1357        let at_variant = one_of
1358            .iter()
1359            .find(|variant| variant["properties"]["kind"]["enum"][0] == "at")
1360            .expect("at variant");
1361        let at_description = at_variant["properties"]["at"]["description"]
1362            .as_str()
1363            .expect("at description");
1364        assert!(
1365            at_description.contains("RFC3339 timestamp with explicit Z or offset"),
1366            "at description should require explicit Z or offset: {at_description}"
1367        );
1368    }
1369}