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 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 "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 } 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 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 let top_required = schema["required"].as_array().expect("top-level required");
1267 assert!(top_required.iter().any(|v| v == "schedule"));
1268
1269 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 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}