1use crate::config::Config;
2use crate::security::SecurityPolicy;
3use anyhow::{Result, anyhow, bail};
4
5mod schedule;
6mod store;
7mod types;
8
9pub mod scheduler;
10
11#[allow(unused_imports)]
12pub use schedule::{
13 next_run_for_schedule, normalize_expression, schedule_cron_expression, validate_schedule,
14};
15#[allow(unused_imports)]
16pub use store::{
17 add_agent_job, all_overdue_jobs, due_jobs, get_job, list_jobs, list_runs, record_last_run,
18 record_run, remove_job, remove_workflow_cron_jobs, reschedule_after_run, sync_declarative_jobs,
19 sync_workflow_cron_jobs, update_job,
20};
21pub use types::{
22 CronJob, CronJobPatch, CronRun, DeliveryConfig, JobType, Schedule, SessionTarget,
23 deserialize_maybe_stringified,
24};
25
26pub fn validate_shell_command(config: &Config, command: &str, approved: bool) -> Result<()> {
31 let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
32 validate_shell_command_with_security(&security, command, approved)
33}
34
35pub(crate) fn validate_shell_command_with_security(
39 security: &SecurityPolicy,
40 command: &str,
41 approved: bool,
42) -> Result<()> {
43 security
44 .validate_command_execution(command, approved)
45 .map(|_| ())
46 .map_err(|reason| anyhow!("blocked by security policy: {reason}"))
47}
48
49pub(crate) fn validate_delivery_config(delivery: Option<&DeliveryConfig>) -> Result<()> {
50 let Some(delivery) = delivery else {
51 return Ok(());
52 };
53
54 if delivery.mode.eq_ignore_ascii_case("none") {
55 return Ok(());
56 }
57 if !delivery.mode.eq_ignore_ascii_case("announce") {
58 bail!("unsupported delivery mode: {}", delivery.mode);
59 }
60
61 let channel = delivery.channel.as_deref().map(str::trim);
62 let Some(channel) = channel.filter(|value| !value.is_empty()) else {
63 bail!("delivery.channel is required for announce mode");
64 };
65 match channel.to_ascii_lowercase().as_str() {
66 "telegram" | "discord" | "slack" | "mattermost" | "signal" | "matrix" | "qq" => {}
67 other => bail!("unsupported delivery channel: {other}"),
68 }
69
70 let has_target = delivery
71 .to
72 .as_deref()
73 .map(str::trim)
74 .is_some_and(|value| !value.is_empty());
75 if !has_target {
76 bail!("delivery.to is required for announce mode");
77 }
78
79 Ok(())
80}
81
82pub fn add_shell_job_with_approval(
87 config: &Config,
88 name: Option<String>,
89 schedule: Schedule,
90 command: &str,
91 delivery: Option<DeliveryConfig>,
92 approved: bool,
93) -> Result<CronJob> {
94 validate_shell_command(config, command, approved)?;
95 validate_delivery_config(delivery.as_ref())?;
96 store::add_shell_job(config, name, schedule, command, delivery)
97}
98
99pub fn update_shell_job_with_approval(
103 config: &Config,
104 job_id: &str,
105 patch: CronJobPatch,
106 approved: bool,
107) -> Result<CronJob> {
108 if let Some(command) = patch.command.as_deref() {
109 validate_shell_command(config, command, approved)?;
110 }
111 update_job(config, job_id, patch)
112}
113
114pub fn add_once_validated(
116 config: &Config,
117 delay: &str,
118 command: &str,
119 approved: bool,
120) -> Result<CronJob> {
121 let duration = parse_delay(delay)?;
122 let at = chrono::Utc::now() + duration;
123 add_once_at_validated(config, at, command, approved)
124}
125
126pub fn add_once_at_validated(
128 config: &Config,
129 at: chrono::DateTime<chrono::Utc>,
130 command: &str,
131 approved: bool,
132) -> Result<CronJob> {
133 let schedule = Schedule::At { at };
134 add_shell_job_with_approval(config, None, schedule, command, None, approved)
135}
136
137pub(crate) fn add_shell_job(
140 config: &Config,
141 name: Option<String>,
142 schedule: Schedule,
143 command: &str,
144) -> Result<CronJob> {
145 add_shell_job_with_approval(config, name, schedule, command, None, false)
146}
147
148pub(crate) fn add_job(config: &Config, expression: &str, command: &str) -> Result<CronJob> {
149 let schedule = Schedule::Cron {
150 expr: expression.to_string(),
151 tz: None,
152 };
153 add_shell_job(config, None, schedule, command)
154}
155
156#[allow(clippy::needless_pass_by_value)]
157pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<()> {
158 match command {
159 crate::CronCommands::List => {
160 let jobs = list_jobs(config)?;
161 if jobs.is_empty() {
162 println!("No scheduled tasks yet.");
163 println!("\nUsage:");
164 println!(" construct cron add '0 9 * * *' 'agent -m \"Good morning!\"'");
165 return Ok(());
166 }
167
168 println!("π Scheduled jobs ({}):", jobs.len());
169 for job in jobs {
170 let last_run = job
171 .last_run
172 .map_or_else(|| "never".into(), |d| d.to_rfc3339());
173 let last_status = job.last_status.unwrap_or_else(|| "n/a".into());
174 println!(
175 "- {} | {:?} | next={} | last={} ({})",
176 job.id,
177 job.schedule,
178 job.next_run.to_rfc3339(),
179 last_run,
180 last_status,
181 );
182 if !job.command.is_empty() {
183 println!(" cmd: {}", job.command);
184 }
185 if let Some(prompt) = &job.prompt {
186 println!(" prompt: {prompt}");
187 }
188 }
189 Ok(())
190 }
191 crate::CronCommands::Add {
192 expression,
193 tz,
194 agent,
195 allowed_tools,
196 command,
197 } => {
198 let schedule = Schedule::Cron {
199 expr: expression,
200 tz,
201 };
202 if agent {
203 let job = add_agent_job(
204 config,
205 None,
206 schedule,
207 &command,
208 SessionTarget::Isolated,
209 None,
210 None,
211 false,
212 if allowed_tools.is_empty() {
213 None
214 } else {
215 Some(allowed_tools)
216 },
217 )?;
218 println!("β
Added agent cron job {}", job.id);
219 println!(" Expr : {}", job.expression);
220 println!(" Next : {}", job.next_run.to_rfc3339());
221 println!(" Prompt: {}", job.prompt.as_deref().unwrap_or_default());
222 } else {
223 if !allowed_tools.is_empty() {
224 bail!("--allowed-tool is only supported with --agent cron jobs");
225 }
226 let job = add_shell_job(config, None, schedule, &command)?;
227 println!("β
Added cron job {}", job.id);
228 println!(" Expr: {}", job.expression);
229 println!(" Next: {}", job.next_run.to_rfc3339());
230 println!(" Cmd : {}", job.command);
231 }
232 Ok(())
233 }
234 crate::CronCommands::AddAt {
235 at,
236 agent,
237 allowed_tools,
238 command,
239 } => {
240 let at = chrono::DateTime::parse_from_rfc3339(&at)
241 .map_err(|e| anyhow::anyhow!("Invalid RFC3339 timestamp for --at: {e}"))?
242 .with_timezone(&chrono::Utc);
243 let schedule = Schedule::At { at };
244 if agent {
245 let job = add_agent_job(
246 config,
247 None,
248 schedule,
249 &command,
250 SessionTarget::Isolated,
251 None,
252 None,
253 true,
254 if allowed_tools.is_empty() {
255 None
256 } else {
257 Some(allowed_tools)
258 },
259 )?;
260 println!("β
Added one-shot agent cron job {}", job.id);
261 println!(" At : {}", job.next_run.to_rfc3339());
262 println!(" Prompt: {}", job.prompt.as_deref().unwrap_or_default());
263 } else {
264 if !allowed_tools.is_empty() {
265 bail!("--allowed-tool is only supported with --agent cron jobs");
266 }
267 let job = add_shell_job(config, None, schedule, &command)?;
268 println!("β
Added one-shot cron job {}", job.id);
269 println!(" At : {}", job.next_run.to_rfc3339());
270 println!(" Cmd : {}", job.command);
271 }
272 Ok(())
273 }
274 crate::CronCommands::AddEvery {
275 every_ms,
276 agent,
277 allowed_tools,
278 command,
279 } => {
280 let schedule = Schedule::Every { every_ms };
281 if agent {
282 let job = add_agent_job(
283 config,
284 None,
285 schedule,
286 &command,
287 SessionTarget::Isolated,
288 None,
289 None,
290 false,
291 if allowed_tools.is_empty() {
292 None
293 } else {
294 Some(allowed_tools)
295 },
296 )?;
297 println!("β
Added interval agent cron job {}", job.id);
298 println!(" Every(ms): {every_ms}");
299 println!(" Next : {}", job.next_run.to_rfc3339());
300 println!(" Prompt : {}", job.prompt.as_deref().unwrap_or_default());
301 } else {
302 if !allowed_tools.is_empty() {
303 bail!("--allowed-tool is only supported with --agent cron jobs");
304 }
305 let job = add_shell_job(config, None, schedule, &command)?;
306 println!("β
Added interval cron job {}", job.id);
307 println!(" Every(ms): {every_ms}");
308 println!(" Next : {}", job.next_run.to_rfc3339());
309 println!(" Cmd : {}", job.command);
310 }
311 Ok(())
312 }
313 crate::CronCommands::Once {
314 delay,
315 agent,
316 allowed_tools,
317 command,
318 } => {
319 if agent {
320 let duration = parse_delay(&delay)?;
321 let at = chrono::Utc::now() + duration;
322 let schedule = Schedule::At { at };
323 let job = add_agent_job(
324 config,
325 None,
326 schedule,
327 &command,
328 SessionTarget::Isolated,
329 None,
330 None,
331 true,
332 if allowed_tools.is_empty() {
333 None
334 } else {
335 Some(allowed_tools)
336 },
337 )?;
338 println!("β
Added one-shot agent cron job {}", job.id);
339 println!(" At : {}", job.next_run.to_rfc3339());
340 println!(" Prompt: {}", job.prompt.as_deref().unwrap_or_default());
341 } else {
342 if !allowed_tools.is_empty() {
343 bail!("--allowed-tool is only supported with --agent cron jobs");
344 }
345 let job = add_once(config, &delay, &command)?;
346 println!("β
Added one-shot cron job {}", job.id);
347 println!(" At : {}", job.next_run.to_rfc3339());
348 println!(" Cmd : {}", job.command);
349 }
350 Ok(())
351 }
352 crate::CronCommands::Update {
353 id,
354 expression,
355 tz,
356 command,
357 name,
358 allowed_tools,
359 } => {
360 if expression.is_none()
361 && tz.is_none()
362 && command.is_none()
363 && name.is_none()
364 && allowed_tools.is_empty()
365 {
366 bail!(
367 "At least one of --expression, --tz, --command, --name, or --allowed-tool must be provided"
368 );
369 }
370
371 let existing = if expression.is_some() || tz.is_some() || !allowed_tools.is_empty() {
372 Some(get_job(config, &id)?)
373 } else {
374 None
375 };
376
377 let schedule = if expression.is_some() || tz.is_some() {
381 let existing = existing
382 .as_ref()
383 .expect("existing job must be loaded when updating schedule");
384 let (existing_expr, existing_tz) = match &existing.schedule {
385 Schedule::Cron {
386 expr,
387 tz: existing_tz,
388 } => (expr.clone(), existing_tz.clone()),
389 _ => bail!("Cannot update expression/tz on a non-cron schedule"),
390 };
391 Some(Schedule::Cron {
392 expr: expression.unwrap_or(existing_expr),
393 tz: tz.or(existing_tz),
394 })
395 } else {
396 None
397 };
398
399 if !allowed_tools.is_empty() {
400 let existing = existing
401 .as_ref()
402 .expect("existing job must be loaded when updating allowed tools");
403 if existing.job_type != JobType::Agent {
404 bail!("--allowed-tool is only supported for agent cron jobs");
405 }
406 }
407
408 let patch = CronJobPatch {
409 schedule,
410 command,
411 name,
412 allowed_tools: if allowed_tools.is_empty() {
413 None
414 } else {
415 Some(allowed_tools)
416 },
417 ..CronJobPatch::default()
418 };
419
420 let job = update_shell_job_with_approval(config, &id, patch, false)?;
421 println!("\u{2705} Updated cron job {}", job.id);
422 println!(" Expr: {}", job.expression);
423 println!(" Next: {}", job.next_run.to_rfc3339());
424 println!(" Cmd : {}", job.command);
425 Ok(())
426 }
427 crate::CronCommands::Remove { id } => remove_job(config, &id),
428 crate::CronCommands::Pause { id } => {
429 pause_job(config, &id)?;
430 println!("βΈοΈ Paused cron job {id}");
431 Ok(())
432 }
433 crate::CronCommands::Resume { id } => {
434 resume_job(config, &id)?;
435 println!("βΆοΈ Resumed cron job {id}");
436 Ok(())
437 }
438 }
439}
440
441pub(crate) fn add_once(config: &Config, delay: &str, command: &str) -> Result<CronJob> {
442 add_once_validated(config, delay, command, false)
443}
444
445pub(crate) fn add_once_at(
446 config: &Config,
447 at: chrono::DateTime<chrono::Utc>,
448 command: &str,
449) -> Result<CronJob> {
450 add_once_at_validated(config, at, command, false)
451}
452
453pub fn pause_job(config: &Config, id: &str) -> Result<CronJob> {
454 update_job(
455 config,
456 id,
457 CronJobPatch {
458 enabled: Some(false),
459 ..CronJobPatch::default()
460 },
461 )
462}
463
464pub fn resume_job(config: &Config, id: &str) -> Result<CronJob> {
465 update_job(
466 config,
467 id,
468 CronJobPatch {
469 enabled: Some(true),
470 ..CronJobPatch::default()
471 },
472 )
473}
474
475fn parse_delay(input: &str) -> Result<chrono::Duration> {
476 let input = input.trim();
477 if input.is_empty() {
478 anyhow::bail!("delay must not be empty");
479 }
480 let split = input
481 .find(|c: char| !c.is_ascii_digit())
482 .unwrap_or(input.len());
483 let (num, unit) = input.split_at(split);
484 let amount: i64 = num.parse()?;
485 let unit = if unit.is_empty() { "m" } else { unit };
486 let duration = match unit {
487 "s" => chrono::Duration::seconds(amount),
488 "m" => chrono::Duration::minutes(amount),
489 "h" => chrono::Duration::hours(amount),
490 "d" => chrono::Duration::days(amount),
491 _ => anyhow::bail!("unsupported delay unit '{unit}', use s/m/h/d"),
492 };
493 Ok(duration)
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499 use tempfile::TempDir;
500
501 fn test_config(tmp: &TempDir) -> Config {
502 let config = Config {
503 workspace_dir: tmp.path().join("workspace"),
504 config_path: tmp.path().join("config.toml"),
505 ..Config::default()
506 };
507 std::fs::create_dir_all(&config.workspace_dir).unwrap();
508 config
509 }
510
511 fn make_job(config: &Config, expr: &str, tz: Option<&str>, cmd: &str) -> CronJob {
512 add_shell_job(
513 config,
514 None,
515 Schedule::Cron {
516 expr: expr.into(),
517 tz: tz.map(Into::into),
518 },
519 cmd,
520 )
521 .unwrap()
522 }
523
524 fn run_update(
525 config: &Config,
526 id: &str,
527 expression: Option<&str>,
528 tz: Option<&str>,
529 command: Option<&str>,
530 name: Option<&str>,
531 ) -> Result<()> {
532 handle_command(
533 crate::CronCommands::Update {
534 id: id.into(),
535 expression: expression.map(Into::into),
536 tz: tz.map(Into::into),
537 command: command.map(Into::into),
538 name: name.map(Into::into),
539 allowed_tools: vec![],
540 },
541 config,
542 )
543 }
544
545 #[test]
546 fn update_changes_command_via_handler() {
547 let tmp = TempDir::new().unwrap();
548 let config = test_config(&tmp);
549 let job = make_job(&config, "*/5 * * * *", None, "echo original");
550
551 run_update(&config, &job.id, None, None, Some("echo updated"), None).unwrap();
552
553 let updated = get_job(&config, &job.id).unwrap();
554 assert_eq!(updated.command, "echo updated");
555 assert_eq!(updated.id, job.id);
556 }
557
558 #[test]
559 fn update_changes_expression_via_handler() {
560 let tmp = TempDir::new().unwrap();
561 let config = test_config(&tmp);
562 let job = make_job(&config, "*/5 * * * *", None, "echo test");
563
564 run_update(&config, &job.id, Some("0 9 * * *"), None, None, None).unwrap();
565
566 let updated = get_job(&config, &job.id).unwrap();
567 assert_eq!(updated.expression, "0 9 * * *");
568 }
569
570 #[test]
571 fn update_changes_name_via_handler() {
572 let tmp = TempDir::new().unwrap();
573 let config = test_config(&tmp);
574 let job = make_job(&config, "*/5 * * * *", None, "echo test");
575
576 run_update(&config, &job.id, None, None, None, Some("new-name")).unwrap();
577
578 let updated = get_job(&config, &job.id).unwrap();
579 assert_eq!(updated.name.as_deref(), Some("new-name"));
580 }
581
582 #[test]
583 fn update_tz_alone_sets_timezone() {
584 let tmp = TempDir::new().unwrap();
585 let config = test_config(&tmp);
586 let job = make_job(&config, "*/5 * * * *", None, "echo test");
587
588 run_update(
589 &config,
590 &job.id,
591 None,
592 Some("America/Los_Angeles"),
593 None,
594 None,
595 )
596 .unwrap();
597
598 let updated = get_job(&config, &job.id).unwrap();
599 assert_eq!(
600 updated.schedule,
601 Schedule::Cron {
602 expr: "*/5 * * * *".into(),
603 tz: Some("America/Los_Angeles".into()),
604 }
605 );
606 }
607
608 #[test]
609 fn update_expression_preserves_existing_tz() {
610 let tmp = TempDir::new().unwrap();
611 let config = test_config(&tmp);
612 let job = make_job(
613 &config,
614 "*/5 * * * *",
615 Some("America/Los_Angeles"),
616 "echo test",
617 );
618
619 run_update(&config, &job.id, Some("0 9 * * *"), None, None, None).unwrap();
620
621 let updated = get_job(&config, &job.id).unwrap();
622 assert_eq!(
623 updated.schedule,
624 Schedule::Cron {
625 expr: "0 9 * * *".into(),
626 tz: Some("America/Los_Angeles".into()),
627 }
628 );
629 }
630
631 #[test]
632 fn update_preserves_unchanged_fields() {
633 let tmp = TempDir::new().unwrap();
634 let config = test_config(&tmp);
635 let job = add_shell_job(
636 &config,
637 Some("original-name".into()),
638 Schedule::Cron {
639 expr: "*/5 * * * *".into(),
640 tz: None,
641 },
642 "echo original",
643 )
644 .unwrap();
645
646 run_update(&config, &job.id, None, None, Some("echo changed"), None).unwrap();
647
648 let updated = get_job(&config, &job.id).unwrap();
649 assert_eq!(updated.command, "echo changed");
650 assert_eq!(updated.name.as_deref(), Some("original-name"));
651 assert_eq!(updated.expression, "*/5 * * * *");
652 }
653
654 #[test]
655 fn update_no_flags_fails() {
656 let tmp = TempDir::new().unwrap();
657 let config = test_config(&tmp);
658 let job = make_job(&config, "*/5 * * * *", None, "echo test");
659
660 let result = run_update(&config, &job.id, None, None, None, None);
661 assert!(result.is_err());
662 assert!(result.unwrap_err().to_string().contains("At least one of"));
663 }
664
665 #[test]
666 fn update_nonexistent_job_fails() {
667 let tmp = TempDir::new().unwrap();
668 let config = test_config(&tmp);
669
670 let result = run_update(
671 &config,
672 "nonexistent-id",
673 None,
674 None,
675 Some("echo test"),
676 None,
677 );
678 assert!(result.is_err());
679 }
680
681 #[test]
682 fn update_security_allows_safe_command() {
683 let tmp = TempDir::new().unwrap();
684 let config = test_config(&tmp);
685
686 let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
687 assert!(security.is_command_allowed("echo safe"));
688 }
689
690 #[test]
691 fn add_shell_job_requires_explicit_approval_for_medium_risk() {
692 let tmp = TempDir::new().unwrap();
693 let mut config = test_config(&tmp);
694 config.autonomy.allowed_commands = vec!["echo".into(), "touch".into()];
695
696 let denied = add_shell_job(
697 &config,
698 None,
699 Schedule::Cron {
700 expr: "*/5 * * * *".into(),
701 tz: None,
702 },
703 "touch cron-medium-risk",
704 );
705 assert!(denied.is_err());
706 assert!(
707 denied
708 .unwrap_err()
709 .to_string()
710 .contains("explicit approval")
711 );
712
713 let approved = add_shell_job_with_approval(
714 &config,
715 None,
716 Schedule::Cron {
717 expr: "*/5 * * * *".into(),
718 tz: None,
719 },
720 "touch cron-medium-risk",
721 None,
722 true,
723 );
724 assert!(approved.is_ok(), "{approved:?}");
725 }
726
727 #[test]
728 fn update_requires_explicit_approval_for_medium_risk() {
729 let tmp = TempDir::new().unwrap();
730 let mut config = test_config(&tmp);
731 config.autonomy.allowed_commands = vec!["echo".into(), "touch".into()];
732 let job = make_job(&config, "*/5 * * * *", None, "echo original");
733
734 let denied = update_shell_job_with_approval(
735 &config,
736 &job.id,
737 CronJobPatch {
738 command: Some("touch cron-medium-risk-update".into()),
739 ..CronJobPatch::default()
740 },
741 false,
742 );
743 assert!(denied.is_err());
744 assert!(
745 denied
746 .unwrap_err()
747 .to_string()
748 .contains("explicit approval")
749 );
750
751 let approved = update_shell_job_with_approval(
752 &config,
753 &job.id,
754 CronJobPatch {
755 command: Some("touch cron-medium-risk-update".into()),
756 ..CronJobPatch::default()
757 },
758 true,
759 )
760 .unwrap();
761 assert_eq!(approved.command, "touch cron-medium-risk-update");
762 }
763
764 #[test]
765 fn cli_update_requires_explicit_approval_for_medium_risk() {
766 let tmp = TempDir::new().unwrap();
767 let mut config = test_config(&tmp);
768 config.autonomy.allowed_commands = vec!["echo".into(), "touch".into()];
769 let job = make_job(&config, "*/5 * * * *", None, "echo original");
770
771 let result = run_update(
772 &config,
773 &job.id,
774 None,
775 None,
776 Some("touch cron-cli-medium-risk"),
777 None,
778 );
779 assert!(result.is_err());
780 assert!(
781 result
782 .unwrap_err()
783 .to_string()
784 .contains("explicit approval")
785 );
786 }
787
788 #[test]
789 fn add_once_validated_creates_one_shot_job() {
790 let tmp = TempDir::new().unwrap();
791 let config = test_config(&tmp);
792
793 let job = add_once_validated(&config, "1h", "echo one-shot", false).unwrap();
794 assert_eq!(job.command, "echo one-shot");
795 assert!(matches!(job.schedule, Schedule::At { .. }));
796 }
797
798 #[test]
799 fn add_once_validated_blocks_disallowed_command() {
800 let tmp = TempDir::new().unwrap();
801 let mut config = test_config(&tmp);
802 config.autonomy.allowed_commands = vec!["echo".into()];
803 config.autonomy.level = crate::security::AutonomyLevel::Supervised;
804
805 let result = add_once_validated(&config, "1h", "curl https://example.com", false);
806 assert!(result.is_err());
807 assert!(
808 result
809 .unwrap_err()
810 .to_string()
811 .contains("blocked by security policy")
812 );
813 }
814
815 #[test]
816 fn add_once_at_validated_creates_one_shot_job() {
817 let tmp = TempDir::new().unwrap();
818 let config = test_config(&tmp);
819 let at = chrono::Utc::now() + chrono::Duration::hours(1);
820
821 let job = add_once_at_validated(&config, at, "echo at-shot", false).unwrap();
822 assert_eq!(job.command, "echo at-shot");
823 assert!(matches!(job.schedule, Schedule::At { .. }));
824 }
825
826 #[test]
827 fn add_once_at_validated_blocks_medium_risk_without_approval() {
828 let tmp = TempDir::new().unwrap();
829 let mut config = test_config(&tmp);
830 config.autonomy.allowed_commands = vec!["echo".into(), "touch".into()];
831 let at = chrono::Utc::now() + chrono::Duration::hours(1);
832
833 let denied = add_once_at_validated(&config, at, "touch at-medium", false);
834 assert!(denied.is_err());
835 assert!(
836 denied
837 .unwrap_err()
838 .to_string()
839 .contains("explicit approval")
840 );
841
842 let approved = add_once_at_validated(&config, at, "touch at-medium", true);
843 assert!(approved.is_ok(), "{approved:?}");
844 }
845
846 #[test]
847 fn gateway_api_path_validates_shell_command() {
848 let tmp = TempDir::new().unwrap();
849 let mut config = test_config(&tmp);
850 config.autonomy.allowed_commands = vec!["echo".into()];
851 config.autonomy.level = crate::security::AutonomyLevel::Supervised;
852
853 let result = add_shell_job_with_approval(
855 &config,
856 None,
857 Schedule::Cron {
858 expr: "*/5 * * * *".into(),
859 tz: None,
860 },
861 "curl https://example.com",
862 None,
863 false,
864 );
865 assert!(result.is_err());
866 assert!(
867 result
868 .unwrap_err()
869 .to_string()
870 .contains("blocked by security policy")
871 );
872 }
873
874 #[test]
875 fn scheduler_path_validates_shell_command() {
876 let tmp = TempDir::new().unwrap();
877 let mut config = test_config(&tmp);
878 config.autonomy.allowed_commands = vec!["echo".into()];
879 config.autonomy.level = crate::security::AutonomyLevel::Supervised;
880
881 let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
882 let result =
884 validate_shell_command_with_security(&security, "curl https://example.com", false);
885 assert!(result.is_err());
886 assert!(
887 result
888 .unwrap_err()
889 .to_string()
890 .contains("blocked by security policy")
891 );
892 }
893
894 #[test]
895 fn cli_agent_flag_creates_agent_job() {
896 let tmp = TempDir::new().unwrap();
897 let config = test_config(&tmp);
898
899 handle_command(
900 crate::CronCommands::Add {
901 expression: "*/15 * * * *".into(),
902 tz: None,
903 agent: true,
904 allowed_tools: vec![],
905 command: "Check server health: disk space, memory, CPU load".into(),
906 },
907 &config,
908 )
909 .unwrap();
910
911 let jobs = list_jobs(&config).unwrap();
912 assert_eq!(jobs.len(), 1);
913 assert_eq!(jobs[0].job_type, JobType::Agent);
914 assert_eq!(
915 jobs[0].prompt.as_deref(),
916 Some("Check server health: disk space, memory, CPU load")
917 );
918 }
919
920 #[test]
921 fn cli_agent_flag_bypasses_shell_security_validation() {
922 let tmp = TempDir::new().unwrap();
923 let mut config = test_config(&tmp);
924 config.autonomy.allowed_commands = vec!["echo".into()];
925 config.autonomy.level = crate::security::AutonomyLevel::Supervised;
926
927 let result = handle_command(
931 crate::CronCommands::Add {
932 expression: "*/15 * * * *".into(),
933 tz: None,
934 agent: true,
935 allowed_tools: vec![],
936 command: "Check server health: disk space, memory, CPU load".into(),
937 },
938 &config,
939 );
940 assert!(result.is_ok());
941
942 let jobs = list_jobs(&config).unwrap();
943 assert_eq!(jobs.len(), 1);
944 assert_eq!(jobs[0].job_type, JobType::Agent);
945 }
946
947 #[test]
948 fn cli_agent_allowed_tools_persist() {
949 let tmp = TempDir::new().unwrap();
950 let config = test_config(&tmp);
951
952 handle_command(
953 crate::CronCommands::Add {
954 expression: "*/15 * * * *".into(),
955 tz: None,
956 agent: true,
957 allowed_tools: vec!["file_read".into(), "web_search".into()],
958 command: "Check server health".into(),
959 },
960 &config,
961 )
962 .unwrap();
963
964 let jobs = list_jobs(&config).unwrap();
965 assert_eq!(jobs.len(), 1);
966 assert_eq!(
967 jobs[0].allowed_tools,
968 Some(vec!["file_read".into(), "web_search".into()])
969 );
970 }
971
972 #[test]
973 fn cli_update_agent_allowed_tools_persist() {
974 let tmp = TempDir::new().unwrap();
975 let config = test_config(&tmp);
976 let job = add_agent_job(
977 &config,
978 Some("agent".into()),
979 Schedule::Cron {
980 expr: "*/5 * * * *".into(),
981 tz: None,
982 },
983 "original prompt",
984 SessionTarget::Isolated,
985 None,
986 None,
987 false,
988 None,
989 )
990 .unwrap();
991
992 handle_command(
993 crate::CronCommands::Update {
994 id: job.id.clone(),
995 expression: None,
996 tz: None,
997 command: None,
998 name: None,
999 allowed_tools: vec!["shell".into()],
1000 },
1001 &config,
1002 )
1003 .unwrap();
1004
1005 let updated = get_job(&config, &job.id).unwrap();
1006 assert_eq!(updated.allowed_tools, Some(vec!["shell".into()]));
1007 }
1008
1009 #[test]
1010 fn cli_without_agent_flag_defaults_to_shell_job() {
1011 let tmp = TempDir::new().unwrap();
1012 let config = test_config(&tmp);
1013
1014 handle_command(
1015 crate::CronCommands::Add {
1016 expression: "*/5 * * * *".into(),
1017 tz: None,
1018 agent: false,
1019 allowed_tools: vec![],
1020 command: "echo ok".into(),
1021 },
1022 &config,
1023 )
1024 .unwrap();
1025
1026 let jobs = list_jobs(&config).unwrap();
1027 assert_eq!(jobs.len(), 1);
1028 assert_eq!(jobs[0].job_type, JobType::Shell);
1029 assert_eq!(jobs[0].command, "echo ok");
1030 }
1031}