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 serde_json::json;
8use std::sync::Arc;
9use zeroclaw_api::tool::{Tool, ToolResult};
10use zeroclaw_config::schema::Config;
11
12pub struct CronAddTool {
13 config: Arc<Config>,
14 security: Arc<SecurityPolicy>,
15 agent_alias: String,
19}
20
21impl CronAddTool {
22 pub fn new(
23 config: Arc<Config>,
24 security: Arc<SecurityPolicy>,
25 agent_alias: impl Into<String>,
26 ) -> Self {
27 Self {
28 config,
29 security,
30 agent_alias: agent_alias.into(),
31 }
32 }
33
34 fn plain_string_schedule_error(raw: &str) -> Option<String> {
35 let schedule = raw.trim();
36 if schedule.starts_with('{') {
37 return None;
38 }
39
40 let got = serde_json::to_string(schedule).unwrap_or_else(|_| "\"<invalid>\"".to_string());
41 Some(format!(
42 "Invalid schedule: expected a JSON object with a \"kind\" field, got plain string {got}. \
43 Use one of: {{\"kind\":\"cron\",\"expr\":\"0 9 * * 1-5\"}}, \
44 {{\"kind\":\"at\",\"at\":\"2025-12-31T23:59:00Z\"}}, or \
45 {{\"kind\":\"every\",\"every_ms\":3600000}}"
46 ))
47 }
48
49 fn enforce_mutation_allowed(&self, action: &str) -> Option<ToolResult> {
50 if !self.security.can_act() {
51 return Some(ToolResult {
52 success: false,
53 output: String::new(),
54 error: Some(format!(
55 "Security policy: read-only mode, cannot perform '{action}'"
56 )),
57 });
58 }
59
60 if self.security.is_rate_limited() {
61 return Some(ToolResult {
62 success: false,
63 output: String::new(),
64 error: Some("Rate limit exceeded: too many actions in the last hour".to_string()),
65 });
66 }
67
68 if !self.security.record_action() {
69 return Some(ToolResult {
70 success: false,
71 output: String::new(),
72 error: Some("Rate limit exceeded: action budget exhausted".to_string()),
73 });
74 }
75
76 None
77 }
78}
79
80#[async_trait]
81impl Tool for CronAddTool {
82 fn name(&self) -> &str {
83 "cron_add"
84 }
85
86 fn description(&self) -> &str {
87 "Create a scheduled cron job (shell or agent) with cron/at/every schedules. \
88 Use job_type='agent' with a prompt to run the AI agent on schedule. \
89 To deliver output to a channel (Discord, Telegram, Slack, Mattermost, Matrix, QQ, Webhook), set \
90 delivery={\"mode\":\"announce\",\"channel\":\"discord\",\"to\":\"<channel_id_or_chat_id>\"}. \
91 For webhook deliveries that must thread through the originating conversation, also set \
92 delivery.thread_id=\"<reply_target>\". \
93 This is the preferred tool for sending scheduled/delayed messages to users via channels."
94 }
95
96 fn parameters_schema(&self) -> serde_json::Value {
97 json!({
98 "type": "object",
99 "properties": {
100 "name": {
101 "type": "string",
102 "description": "Optional human-readable name for the job"
103 },
104 "schedule": {
109 "description": "When to run the job. Exactly one of three forms must be used.",
110 "oneOf": [
111 {
112 "type": "object",
113 "description": "Cron expression schedule (repeating). Example: {\"kind\":\"cron\",\"expr\":\"0 9 * * 1-5\",\"tz\":\"America/New_York\"}",
114 "properties": {
115 "kind": { "type": "string", "enum": ["cron"] },
116 "expr": { "type": "string", "description": "Standard 5-field cron expression, e.g. '*/5 * * * *'" },
117 "tz": { "type": "string", "description": CRON_TZ_DESCRIPTION }
118 },
119 "required": ["kind", "expr"]
120 },
121 {
122 "type": "object",
123 "description": "One-shot schedule at a specific RFC3339 timestamp with explicit Z or offset. Example: {\"kind\":\"at\",\"at\":\"2025-12-31T23:59:00Z\"}",
124 "properties": {
125 "kind": { "type": "string", "enum": ["at"] },
126 "at": { "type": "string", "description": AT_DESCRIPTION }
127 },
128 "required": ["kind", "at"]
129 },
130 {
131 "type": "object",
132 "description": "Repeating interval schedule in milliseconds. Example: {\"kind\":\"every\",\"every_ms\":3600000} runs every hour.",
133 "properties": {
134 "kind": { "type": "string", "enum": ["every"] },
135 "every_ms": { "type": "integer", "description": "Interval in milliseconds, e.g. 3600000 for every hour" }
136 },
137 "required": ["kind", "every_ms"]
138 }
139 ]
140 },
141 "job_type": {
142 "type": "string",
143 "enum": ["shell", "agent"],
144 "description": "Type of job: 'shell' runs a command, 'agent' runs the AI agent with a prompt"
145 },
146 "command": {
147 "type": "string",
148 "description": "Shell command to run (required when job_type is 'shell')"
149 },
150 "prompt": {
151 "type": "string",
152 "description": "Agent prompt to run on schedule (required when job_type is 'agent')"
153 },
154 "session_target": {
155 "type": "string",
156 "enum": ["isolated", "main"],
157 "description": "Agent session context: 'isolated' starts a fresh session each run, 'main' reuses the primary session"
158 },
159 "model": {
160 "type": "string",
161 "description": "Optional model override for agent jobs, e.g. 'x-ai/grok-4-1-fast'"
162 },
163 "allowed_tools": {
164 "type": "array",
165 "items": { "type": "string" },
166 "description": "Optional allowlist of tool names for agent jobs. When omitted, all tools remain available."
167 },
168 "delivery": {
169 "type": "object",
170 "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.",
171 "properties": {
172 "mode": {
173 "type": "string",
174 "enum": ["none", "announce"],
175 "description": "'announce' sends output to the specified channel; 'none' disables delivery"
176 },
177 "channel": {
178 "type": "string",
179 "enum": ["telegram", "discord", "slack", "mattermost", "matrix", "qq", "webhook", "lark", "feishu"],
180 "description": "Channel type to deliver output to"
181 },
182 "to": {
183 "type": "string",
184 "description": "Destination ID: Discord channel ID, Telegram chat ID, Slack channel name, webhook recipient, etc."
185 },
186 "thread_id": {
187 "type": "string",
188 "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`."
189 },
190 "best_effort": {
191 "type": "boolean",
192 "description": "If true, a delivery failure does not fail the job itself. Defaults to true."
193 }
194 }
195 },
196 "delete_after_run": {
197 "type": "boolean",
198 "description": "If true, the job is automatically deleted after its first successful run. Defaults to true for 'at' schedules."
199 },
200 "approved": {
201 "type": "boolean",
202 "description": "Set true to explicitly approve medium/high-risk shell commands in supervised mode",
203 "default": false
204 }
205 },
206 "required": ["schedule"]
207 })
208 }
209
210 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
211 if !self.config.scheduler.enabled {
212 return Ok(ToolResult {
213 success: false,
214 output: String::new(),
215 error: Some("cron is disabled by config (scheduler.enabled=false)".to_string()),
216 });
217 }
218
219 let schedule = match args.get("schedule") {
220 Some(v @ serde_json::Value::String(raw)) => {
221 if let Some(error) = Self::plain_string_schedule_error(raw) {
222 return Ok(ToolResult {
223 success: false,
224 output: String::new(),
225 error: Some(error),
226 });
227 }
228
229 match deserialize_schedule_arg(v) {
230 Ok(schedule) => schedule,
231 Err(error) => {
232 return Ok(ToolResult {
233 success: false,
234 output: String::new(),
235 error: Some(error),
236 });
237 }
238 }
239 }
240 Some(v) => match deserialize_schedule_arg(v) {
241 Ok(schedule) => schedule,
242 Err(error) => {
243 return Ok(ToolResult {
244 success: false,
245 output: String::new(),
246 error: Some(error),
247 });
248 }
249 },
250 None => {
251 return Ok(ToolResult {
252 success: false,
253 output: String::new(),
254 error: Some("Missing 'schedule' parameter".to_string()),
255 });
256 }
257 };
258
259 let name = args
260 .get("name")
261 .and_then(serde_json::Value::as_str)
262 .map(str::to_string);
263
264 let job_type = match args.get("job_type").and_then(serde_json::Value::as_str) {
265 Some("agent") => JobType::Agent,
266 Some("shell") => JobType::Shell,
267 Some(other) => {
268 return Ok(ToolResult {
269 success: false,
270 output: String::new(),
271 error: Some(format!("Invalid job_type: {other}")),
272 });
273 }
274 None => {
275 if args.get("prompt").is_some() {
276 JobType::Agent
277 } else {
278 JobType::Shell
279 }
280 }
281 };
282
283 let default_delete_after_run = matches!(schedule, Schedule::At { .. });
284 let delete_after_run = args
285 .get("delete_after_run")
286 .and_then(serde_json::Value::as_bool)
287 .unwrap_or(default_delete_after_run);
288 let approved = args
289 .get("approved")
290 .and_then(serde_json::Value::as_bool)
291 .unwrap_or(false);
292 let delivery = match args.get("delivery") {
293 Some(v) => match serde_json::from_value::<DeliveryConfig>(v.clone()) {
294 Ok(cfg) => Some(cfg),
295 Err(e) => {
296 return Ok(ToolResult {
297 success: false,
298 output: String::new(),
299 error: Some(format!("Invalid delivery config: {e}")),
300 });
301 }
302 },
303 None => None,
304 };
305
306 let result = match job_type {
307 JobType::Shell => {
308 let command = match args.get("command").and_then(serde_json::Value::as_str) {
309 Some(command) if !command.trim().is_empty() => command,
310 _ => {
311 return Ok(ToolResult {
312 success: false,
313 output: String::new(),
314 error: Some("Missing 'command' for shell job".to_string()),
315 });
316 }
317 };
318
319 if let Err(reason) = self.security.validate_command_execution(command, approved) {
320 return Ok(ToolResult {
321 success: false,
322 output: String::new(),
323 error: Some(reason),
324 });
325 }
326
327 if let Some(blocked) = self.enforce_mutation_allowed("cron_add") {
328 return Ok(blocked);
329 }
330
331 cron::add_shell_job_with_approval(
332 &self.config,
333 &self.agent_alias,
334 name,
335 schedule,
336 command,
337 delivery,
338 approved,
339 )
340 }
341 JobType::Agent => {
342 let prompt = match args.get("prompt").and_then(serde_json::Value::as_str) {
343 Some(prompt) if !prompt.trim().is_empty() => prompt,
344 _ => {
345 return Ok(ToolResult {
346 success: false,
347 output: String::new(),
348 error: Some("Missing 'prompt' for agent job".to_string()),
349 });
350 }
351 };
352
353 let session_target = match args.get("session_target") {
354 Some(v) => match serde_json::from_value::<SessionTarget>(v.clone()) {
355 Ok(target) => target,
356 Err(e) => {
357 return Ok(ToolResult {
358 success: false,
359 output: String::new(),
360 error: Some(format!("Invalid session_target: {e}")),
361 });
362 }
363 },
364 None => SessionTarget::Isolated,
365 };
366
367 let model = args
368 .get("model")
369 .and_then(serde_json::Value::as_str)
370 .map(str::to_string);
371 let allowed_tools = match args.get("allowed_tools") {
372 Some(v) => match serde_json::from_value::<Vec<String>>(v.clone()) {
373 Ok(v) => {
374 if v.is_empty() {
375 None } else {
377 Some(v)
378 }
379 }
380 Err(e) => {
381 return Ok(ToolResult {
382 success: false,
383 output: String::new(),
384 error: Some(format!("Invalid allowed_tools: {e}")),
385 });
386 }
387 },
388 None => None,
389 };
390
391 if let Some(blocked) = self.enforce_mutation_allowed("cron_add") {
392 return Ok(blocked);
393 }
394
395 cron::add_agent_job(
396 &self.config,
397 &self.agent_alias,
398 name,
399 schedule,
400 prompt,
401 session_target,
402 model,
403 delivery,
404 delete_after_run,
405 allowed_tools,
406 )
407 }
408 };
409
410 match result {
411 Ok(job) => Ok(ToolResult {
412 success: true,
413 output: serde_json::to_string_pretty(&cron_add_output(&job))?,
414 error: None,
415 }),
416 Err(e) => Ok(ToolResult {
417 success: false,
418 output: String::new(),
419 error: Some(e.to_string()),
420 }),
421 }
422 }
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428 use crate::security::AutonomyLevel;
429 use tempfile::TempDir;
430 use zeroclaw_config::schema::Config;
431
432 const TEST_AGENT: &str = "test-agent";
433
434 async fn test_config(tmp: &TempDir) -> Arc<Config> {
435 let mut config = Config {
436 data_dir: tmp.path().join("data"),
437 config_path: tmp.path().join("config.toml"),
438 ..Config::default()
439 };
440 seed_test_agent(&mut config);
441 tokio::fs::create_dir_all(&config.data_dir).await.unwrap();
442 Arc::new(config)
443 }
444
445 fn test_security(cfg: &Config) -> Arc<SecurityPolicy> {
446 Arc::new(
447 SecurityPolicy::for_agent(cfg, TEST_AGENT).expect("test-agent has resolvable profiles"),
448 )
449 }
450
451 fn seed_test_agent(config: &mut Config) {
452 config
453 .risk_profiles
454 .entry(TEST_AGENT.to_string())
455 .or_default();
456 config
457 .runtime_profiles
458 .entry(TEST_AGENT.to_string())
459 .or_default();
460 config
461 .providers
462 .models
463 .ensure("openrouter", TEST_AGENT)
464 .expect("known family");
465 config.agents.entry(TEST_AGENT.to_string()).or_insert(
466 zeroclaw_config::schema::AliasedAgentConfig {
467 model_provider: format!("openrouter.{TEST_AGENT}").into(),
468 risk_profile: TEST_AGENT.to_string(),
469 runtime_profile: TEST_AGENT.to_string(),
470 ..Default::default()
471 },
472 );
473 }
474
475 #[tokio::test]
476 async fn adds_shell_job() {
477 let tmp = TempDir::new().unwrap();
478 let cfg = test_config(&tmp).await;
479 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
480 let result = tool
481 .execute(json!({
482 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
483 "job_type": "shell",
484 "command": "echo ok"
485 }))
486 .await
487 .unwrap();
488
489 assert!(result.success, "{:?}", result.error);
490 assert!(result.output.contains("next_run"));
491 }
492
493 #[tokio::test]
494 async fn output_includes_timezone_confirmation_fields_for_explicit_cron_timezone() {
495 let tmp = TempDir::new().unwrap();
496 let cfg = test_config(&tmp).await;
497 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
498 let result = tool
499 .execute(json!({
500 "schedule": { "kind": "cron", "expr": "0 9 * * 1-5", "tz": "America/New_York" },
501 "job_type": "shell",
502 "command": "echo ok"
503 }))
504 .await
505 .unwrap();
506
507 assert!(result.success, "{:?}", result.error);
508 let output: serde_json::Value = serde_json::from_str(&result.output).unwrap();
509 assert_eq!(output["next_run"], output["next_run_utc"]);
510 assert_eq!(output["schedule_timezone"], "America/New_York");
511 assert_eq!(output["timezone_source"], "explicit");
512 assert!(
513 output["next_run_local"]
514 .as_str()
515 .is_some_and(|value| value.contains("T09:00:00")),
516 "next_run_local should display the next run in the explicit schedule timezone: {output}"
517 );
518 }
519
520 #[tokio::test]
521 async fn output_identifies_runtime_local_fallback_when_cron_timezone_is_omitted() {
522 let tmp = TempDir::new().unwrap();
523 let cfg = test_config(&tmp).await;
524 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
525 let result = tool
526 .execute(json!({
527 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
528 "job_type": "shell",
529 "command": "echo ok"
530 }))
531 .await
532 .unwrap();
533
534 assert!(result.success, "{:?}", result.error);
535 let output: serde_json::Value = serde_json::from_str(&result.output).unwrap();
536 assert_eq!(output["timezone_source"], "runtime_local");
537 assert_eq!(output["schedule_timezone"], "runtime local timezone");
538 assert!(
539 output["next_run_local"].as_str().is_some(),
540 "next_run_local should be present for runtime-local cron schedules: {output}"
541 );
542 }
543
544 #[tokio::test]
545 async fn shell_job_persists_delivery() {
546 let tmp = TempDir::new().unwrap();
547 let cfg = test_config(&tmp).await;
548 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
549 let result = tool
550 .execute(json!({
551 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
552 "job_type": "shell",
553 "command": "echo ok",
554 "delivery": {
555 "mode": "announce",
556 "channel": "discord",
557 "to": "1234567890",
558 "best_effort": true
559 }
560 }))
561 .await
562 .unwrap();
563
564 assert!(result.success, "{:?}", result.error);
565
566 let jobs = cron::list_jobs(&cfg).unwrap();
567 assert_eq!(jobs.len(), 1);
568 assert_eq!(jobs[0].delivery.mode, "announce");
569 assert_eq!(jobs[0].delivery.channel.as_deref(), Some("discord"));
570 assert_eq!(jobs[0].delivery.to.as_deref(), Some("1234567890"));
571 assert!(jobs[0].delivery.best_effort);
572 }
573
574 #[tokio::test]
575 async fn blocks_disallowed_shell_command() {
576 let tmp = TempDir::new().unwrap();
577 let mut config = Config {
578 data_dir: tmp.path().join("data"),
579 config_path: tmp.path().join("config.toml"),
580 ..Config::default()
581 };
582 seed_test_agent(&mut config);
583 config
584 .risk_profiles
585 .entry(TEST_AGENT.into())
586 .or_default()
587 .allowed_commands = vec!["echo".into()];
588 config
589 .risk_profiles
590 .entry(TEST_AGENT.into())
591 .or_default()
592 .level = AutonomyLevel::Supervised;
593 tokio::fs::create_dir_all(&config.data_dir).await.unwrap();
594 let cfg = Arc::new(config);
595 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
596
597 let result = tool
598 .execute(json!({
599 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
600 "job_type": "shell",
601 "command": "curl https://example.com"
602 }))
603 .await
604 .unwrap();
605
606 assert!(!result.success);
607 assert!(result.error.unwrap_or_default().contains("not allowed"));
608 }
609
610 #[tokio::test]
611 async fn blocks_mutation_in_read_only_mode() {
612 let tmp = TempDir::new().unwrap();
613 let mut config = Config {
614 data_dir: tmp.path().join("data"),
615 config_path: tmp.path().join("config.toml"),
616 ..Config::default()
617 };
618 seed_test_agent(&mut config);
619 config
620 .risk_profiles
621 .entry(TEST_AGENT.into())
622 .or_default()
623 .level = AutonomyLevel::ReadOnly;
624 std::fs::create_dir_all(&config.data_dir).unwrap();
625 let cfg = Arc::new(config);
626 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
627
628 let result = tool
629 .execute(json!({
630 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
631 "job_type": "shell",
632 "command": "echo ok"
633 }))
634 .await
635 .unwrap();
636
637 assert!(!result.success);
638 let error = result.error.unwrap_or_default();
639 assert!(error.contains("read-only") || error.contains("not allowed"));
640 }
641
642 #[tokio::test]
643 async fn blocks_add_when_rate_limited() {
644 let tmp = TempDir::new().unwrap();
645 let mut config = Config {
646 data_dir: tmp.path().join("data"),
647 config_path: tmp.path().join("config.toml"),
648 ..Config::default()
649 };
650 seed_test_agent(&mut config);
651 config
652 .risk_profiles
653 .entry(TEST_AGENT.into())
654 .or_default()
655 .level = AutonomyLevel::Full;
656 config
657 .runtime_profiles
658 .entry(TEST_AGENT.into())
659 .or_default()
660 .max_actions_per_hour = 0;
661 std::fs::create_dir_all(&config.data_dir).unwrap();
662 let cfg = Arc::new(config);
663 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
664
665 let result = tool
666 .execute(json!({
667 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
668 "job_type": "shell",
669 "command": "echo ok"
670 }))
671 .await
672 .unwrap();
673
674 assert!(!result.success);
675 assert!(
676 result
677 .error
678 .unwrap_or_default()
679 .contains("Rate limit exceeded")
680 );
681 assert!(cron::list_jobs(&cfg).unwrap().is_empty());
682 }
683
684 #[tokio::test]
685 async fn medium_risk_shell_command_requires_approval() {
686 let tmp = TempDir::new().unwrap();
687 let mut config = Config {
688 data_dir: tmp.path().join("data"),
689 config_path: tmp.path().join("config.toml"),
690 ..Config::default()
691 };
692 seed_test_agent(&mut config);
693 config
694 .risk_profiles
695 .entry(TEST_AGENT.into())
696 .or_default()
697 .allowed_commands = vec!["touch".into()];
698 config
699 .risk_profiles
700 .entry(TEST_AGENT.into())
701 .or_default()
702 .level = AutonomyLevel::Supervised;
703 std::fs::create_dir_all(&config.data_dir).unwrap();
704 let cfg = Arc::new(config);
705 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
706
707 let denied = tool
708 .execute(json!({
709 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
710 "job_type": "shell",
711 "command": "touch cron-approval-test"
712 }))
713 .await
714 .unwrap();
715 assert!(!denied.success);
716 assert!(
717 denied
718 .error
719 .unwrap_or_default()
720 .contains("explicit approval")
721 );
722
723 let approved = tool
724 .execute(json!({
725 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
726 "job_type": "shell",
727 "command": "touch cron-approval-test",
728 "approved": true
729 }))
730 .await
731 .unwrap();
732 assert!(approved.success, "{:?}", approved.error);
733 }
734
735 #[tokio::test]
736 async fn accepts_schedule_passed_as_json_string() {
737 let tmp = TempDir::new().unwrap();
738 let cfg = test_config(&tmp).await;
739 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
740
741 let result = tool
744 .execute(json!({
745 "schedule": r#"{"kind":"cron","expr":"*/5 * * * *"}"#,
746 "job_type": "shell",
747 "command": "echo string-schedule"
748 }))
749 .await
750 .unwrap();
751
752 assert!(result.success, "{:?}", result.error);
753 assert!(result.output.contains("next_run"));
754 }
755
756 #[tokio::test]
757 async fn rejects_plain_string_schedule_with_actionable_error() {
758 let tmp = TempDir::new().unwrap();
759 let cfg = test_config(&tmp).await;
760 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
761
762 let result = tool
763 .execute(json!({
764 "schedule": "0 9 * * 1-5",
765 "job_type": "shell",
766 "command": "echo bad-schedule"
767 }))
768 .await
769 .unwrap();
770
771 assert!(!result.success);
772 let error = result.error.unwrap_or_default();
773 assert!(error.contains("expected a JSON object"));
774 assert!(error.contains("\"kind\""));
775 assert!(error.contains("plain string \"0 9 * * 1-5\""));
776 assert!(error.contains("{\"kind\":\"cron\",\"expr\":\"0 9 * * 1-5\"}"));
777 assert!(error.contains("{\"kind\":\"at\",\"at\":\"2025-12-31T23:59:00Z\"}"));
778 assert!(error.contains("{\"kind\":\"every\",\"every_ms\":3600000}"));
779 assert!(!error.contains("internally tagged enum"));
780 }
781
782 #[tokio::test]
783 async fn accepts_stringified_interval_schedule() {
784 let tmp = TempDir::new().unwrap();
785 let cfg = test_config(&tmp).await;
786 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
787
788 let result = tool
789 .execute(json!({
790 "schedule": r#"{"kind":"every","every_ms":60000}"#,
791 "job_type": "shell",
792 "command": "echo interval"
793 }))
794 .await
795 .unwrap();
796
797 assert!(result.success, "{:?}", result.error);
798 }
799
800 #[tokio::test]
801 async fn accepts_stringified_schedule_with_timezone() {
802 let tmp = TempDir::new().unwrap();
803 let cfg = test_config(&tmp).await;
804 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
805
806 let result = tool
807 .execute(json!({
808 "schedule": r#"{"kind":"cron","expr":"*/30 9-15 * * 1-5","tz":"Asia/Shanghai"}"#,
809 "job_type": "shell",
810 "command": "echo tz-test"
811 }))
812 .await
813 .unwrap();
814
815 assert!(result.success, "{:?}", result.error);
816 }
817
818 #[tokio::test]
819 async fn rejects_invalid_schedule() {
820 let tmp = TempDir::new().unwrap();
821 let cfg = test_config(&tmp).await;
822 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
823
824 let result = tool
825 .execute(json!({
826 "schedule": { "kind": "every", "every_ms": 0 },
827 "job_type": "shell",
828 "command": "echo nope"
829 }))
830 .await
831 .unwrap();
832
833 assert!(!result.success);
834 assert!(
835 result
836 .error
837 .unwrap_or_default()
838 .contains("every_ms must be > 0")
839 );
840 }
841
842 #[tokio::test]
843 async fn rejects_at_timestamp_without_explicit_offset_with_actionable_error() {
844 let tmp = TempDir::new().unwrap();
845 let cfg = test_config(&tmp).await;
846 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
847
848 let result = tool
849 .execute(json!({
850 "schedule": { "kind": "at", "at": "2026-05-18T09:00:00" },
851 "job_type": "shell",
852 "command": "echo at"
853 }))
854 .await
855 .unwrap();
856
857 assert!(!result.success);
858 let error = result.error.unwrap_or_default();
859 assert!(
860 error.contains("RFC3339 timestamp with explicit Z or offset"),
861 "error should explain the explicit offset requirement: {error}"
862 );
863 assert!(error.contains("2026-05-18T09:00:00Z"));
864 assert!(error.contains("2026-05-18T09:00:00-04:00"));
865 }
866
867 #[tokio::test]
868 async fn agent_job_requires_prompt() {
869 let tmp = TempDir::new().unwrap();
870 let cfg = test_config(&tmp).await;
871 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
872
873 let result = tool
874 .execute(json!({
875 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
876 "job_type": "agent"
877 }))
878 .await
879 .unwrap();
880 assert!(!result.success);
881 assert!(
882 result
883 .error
884 .unwrap_or_default()
885 .contains("Missing 'prompt'")
886 );
887 }
888
889 #[tokio::test]
890 async fn agent_job_persists_allowed_tools() {
891 let tmp = TempDir::new().unwrap();
892 let cfg = test_config(&tmp).await;
893 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
894
895 let result = tool
896 .execute(json!({
897 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
898 "job_type": "agent",
899 "prompt": "check status",
900 "allowed_tools": ["file_read", "web_search"]
901 }))
902 .await
903 .unwrap();
904
905 assert!(result.success, "{:?}", result.error);
906
907 let jobs = cron::list_jobs(&cfg).unwrap();
908 assert_eq!(jobs.len(), 1);
909 assert_eq!(
910 jobs[0].allowed_tools,
911 Some(vec!["file_read".into(), "web_search".into()])
912 );
913 }
914
915 #[tokio::test]
916 async fn empty_allowed_tools_stored_as_none() {
917 let tmp = TempDir::new().unwrap();
918 let cfg = test_config(&tmp).await;
919 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
920
921 let result = tool
922 .execute(json!({
923 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
924 "job_type": "agent",
925 "prompt": "check status",
926 "allowed_tools": []
927 }))
928 .await
929 .unwrap();
930
931 assert!(result.success, "{:?}", result.error);
932
933 let jobs = cron::list_jobs(&cfg).unwrap();
934 assert_eq!(jobs.len(), 1);
935 assert_eq!(
936 jobs[0].allowed_tools, None,
937 "empty allowed_tools should be stored as None"
938 );
939 }
940
941 #[tokio::test]
942 async fn delivery_schema_includes_matrix_channel() {
943 let tmp = TempDir::new().unwrap();
944 let cfg = test_config(&tmp).await;
945 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
946
947 let values =
948 tool.parameters_schema()["properties"]["delivery"]["properties"]["channel"]["enum"]
949 .as_array()
950 .cloned()
951 .unwrap_or_default();
952
953 assert!(values.iter().any(|value| value == "matrix"));
954 }
955
956 #[tokio::test]
957 async fn delivery_schema_includes_webhook_and_thread_id() {
958 let tmp = TempDir::new().unwrap();
959 let cfg = test_config(&tmp).await;
960 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
961 let schema = tool.parameters_schema();
962
963 let channel_enum = schema["properties"]["delivery"]["properties"]["channel"]["enum"]
964 .as_array()
965 .cloned()
966 .unwrap_or_default();
967 assert!(
968 channel_enum.iter().any(|value| value == "webhook"),
969 "delivery.channel enum must include webhook"
970 );
971
972 let delivery_props = schema["properties"]["delivery"]["properties"]
973 .as_object()
974 .expect("delivery must have properties");
975 assert!(
976 delivery_props.contains_key("thread_id"),
977 "delivery schema must expose thread_id so the webhook channel can route callbacks"
978 );
979 }
980
981 #[tokio::test]
982 async fn webhook_announce_job_persists_thread_id() {
983 let tmp = TempDir::new().unwrap();
984 let cfg = test_config(&tmp).await;
985 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
986 let result = tool
987 .execute(json!({
988 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
989 "job_type": "shell",
990 "command": "echo ok",
991 "delivery": {
992 "mode": "announce",
993 "channel": "webhook",
994 "to": "user-42",
995 "thread_id": "conv-99",
996 "best_effort": true
997 }
998 }))
999 .await
1000 .unwrap();
1001
1002 assert!(result.success, "{:?}", result.error);
1003
1004 let jobs = cron::list_jobs(&cfg).unwrap();
1005 assert_eq!(jobs.len(), 1);
1006 assert_eq!(jobs[0].delivery.mode, "announce");
1007 assert_eq!(jobs[0].delivery.channel.as_deref(), Some("webhook"));
1008 assert_eq!(jobs[0].delivery.to.as_deref(), Some("user-42"));
1009 assert_eq!(jobs[0].delivery.thread_id.as_deref(), Some("conv-99"));
1010 assert!(jobs[0].delivery.best_effort);
1011 }
1012
1013 #[test]
1014 fn schedule_schema_is_oneof_with_cron_at_every_variants() {
1015 let tmp = tempfile::TempDir::new().unwrap();
1016 let cfg = Arc::new(Config {
1017 data_dir: tmp.path().join("data"),
1018 config_path: tmp.path().join("config.toml"),
1019 ..Config::default()
1020 });
1021 let security = Arc::new(SecurityPolicy::from_risk_profile(
1022 &zeroclaw_config::schema::RiskProfileConfig::default(),
1023 &cfg.data_dir,
1024 ));
1025 let tool = CronAddTool::new(cfg, security, TEST_AGENT);
1026 let schema = tool.parameters_schema();
1027
1028 let top_required = schema["required"].as_array().expect("top-level required");
1030 assert!(top_required.iter().any(|v| v == "schedule"));
1031
1032 let one_of = schema["properties"]["schedule"]["oneOf"]
1034 .as_array()
1035 .expect("schedule.oneOf must be an array");
1036 assert_eq!(one_of.len(), 3, "expected cron, at, and every variants");
1037
1038 let kinds: Vec<&str> = one_of
1039 .iter()
1040 .filter_map(|v| v["properties"]["kind"]["enum"][0].as_str())
1041 .collect();
1042 assert!(kinds.contains(&"cron"), "missing cron variant");
1043 assert!(kinds.contains(&"at"), "missing at variant");
1044 assert!(kinds.contains(&"every"), "missing every variant");
1045
1046 for variant in one_of {
1048 let kind = variant["properties"]["kind"]["enum"][0]
1049 .as_str()
1050 .expect("variant kind");
1051 let req: Vec<&str> = variant["required"]
1052 .as_array()
1053 .unwrap_or_else(|| panic!("{kind} variant must have required"))
1054 .iter()
1055 .filter_map(|v| v.as_str())
1056 .collect();
1057 assert!(
1058 req.contains(&"kind"),
1059 "{kind} variant missing 'kind' in required"
1060 );
1061 match kind {
1062 "cron" => assert!(req.contains(&"expr"), "cron variant missing 'expr'"),
1063 "at" => assert!(req.contains(&"at"), "at variant missing 'at'"),
1064 "every" => {
1065 assert!(
1066 req.contains(&"every_ms"),
1067 "every variant missing 'every_ms'"
1068 );
1069 assert_eq!(
1070 variant["properties"]["every_ms"]["type"].as_str(),
1071 Some("integer"),
1072 "every_ms must be typed as integer"
1073 );
1074 }
1075 _ => panic!("unexpected kind: {kind}"),
1076 }
1077 }
1078
1079 let cron_variant = one_of
1080 .iter()
1081 .find(|variant| variant["properties"]["kind"]["enum"][0] == "cron")
1082 .expect("cron variant");
1083 let cron_tz_description = cron_variant["properties"]["tz"]["description"]
1084 .as_str()
1085 .expect("cron tz description");
1086 assert!(
1087 cron_tz_description.contains("runtime local timezone"),
1088 "cron tz description must match scheduler fallback: {cron_tz_description}"
1089 );
1090 assert!(
1091 cron_tz_description.contains("explicit IANA timezone"),
1092 "cron tz description should recommend explicit IANA timezones: {cron_tz_description}"
1093 );
1094 assert!(
1095 !cron_tz_description.contains("Defaults to UTC"),
1096 "cron tz description must not claim a UTC default"
1097 );
1098
1099 let at_variant = one_of
1100 .iter()
1101 .find(|variant| variant["properties"]["kind"]["enum"][0] == "at")
1102 .expect("at variant");
1103 let at_description = at_variant["properties"]["at"]["description"]
1104 .as_str()
1105 .expect("at description");
1106 assert!(
1107 at_description.contains("RFC3339 timestamp with explicit Z or offset"),
1108 "at description should require explicit Z or offset: {at_description}"
1109 );
1110 }
1111}