1use super::cron_common::{
2 AT_DESCRIPTION, CRON_TZ_DESCRIPTION, cron_job_output, deserialize_patch_arg,
3};
4use crate::cron;
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 CronUpdateTool {
13 config: Arc<Config>,
14 security: Arc<SecurityPolicy>,
15 agent_alias: String,
17}
18
19impl CronUpdateTool {
20 pub fn new(
21 config: Arc<Config>,
22 security: Arc<SecurityPolicy>,
23 agent_alias: impl Into<String>,
24 ) -> Self {
25 Self {
26 config,
27 security,
28 agent_alias: agent_alias.into(),
29 }
30 }
31
32 fn enforce_mutation_allowed(&self, action: &str) -> Option<ToolResult> {
33 if !self.security.can_act() {
34 return Some(ToolResult {
35 success: false,
36 output: String::new(),
37 error: Some(format!(
38 "Security policy: read-only mode, cannot perform '{action}'"
39 )),
40 });
41 }
42
43 if self.security.is_rate_limited() {
44 return Some(ToolResult {
45 success: false,
46 output: String::new(),
47 error: Some("Rate limit exceeded: too many actions in the last hour".to_string()),
48 });
49 }
50
51 if !self.security.record_action() {
52 return Some(ToolResult {
53 success: false,
54 output: String::new(),
55 error: Some("Rate limit exceeded: action budget exhausted".to_string()),
56 });
57 }
58
59 None
60 }
61}
62
63#[async_trait]
64impl Tool for CronUpdateTool {
65 fn name(&self) -> &str {
66 "cron_update"
67 }
68
69 fn description(&self) -> &str {
70 "Patch an existing cron job (schedule, command, prompt, enabled, delivery, model, etc.)"
71 }
72
73 fn parameters_schema(&self) -> serde_json::Value {
74 json!({
75 "type": "object",
76 "properties": {
77 "job_id": {
78 "type": "string",
79 "description": "ID of the cron job to update, as returned by cron_add or cron_list"
80 },
81 "patch": {
82 "type": "object",
83 "description": "Fields to update. Only include fields you want to change; omitted fields are left as-is.",
84 "properties": {
85 "name": {
86 "type": "string",
87 "description": "New human-readable name for the job"
88 },
89 "enabled": {
90 "type": "boolean",
91 "description": "Enable or disable the job without deleting it"
92 },
93 "command": {
94 "type": "string",
95 "description": "New shell command (for shell jobs)"
96 },
97 "prompt": {
98 "type": "string",
99 "description": "New agent prompt (for agent jobs)"
100 },
101 "model": {
102 "type": "string",
103 "description": "Model override for agent jobs, e.g. 'x-ai/grok-4-1-fast'"
104 },
105 "allowed_tools": {
106 "type": "array",
107 "items": { "type": "string" },
108 "description": "Optional replacement allowlist of tool names for agent jobs"
109 },
110 "session_target": {
111 "type": "string",
112 "enum": ["isolated", "main"],
113 "description": "Agent session context: 'isolated' starts fresh each run, 'main' reuses the primary session"
114 },
115 "delete_after_run": {
116 "type": "boolean",
117 "description": "If true, delete the job automatically after its first successful run"
118 },
119 "schedule": {
124 "description": "New schedule for the job. Exactly one of three forms must be used.",
125 "oneOf": [
126 {
127 "type": "object",
128 "description": "Cron expression schedule (repeating). Example: {\"kind\":\"cron\",\"expr\":\"0 9 * * 1-5\",\"tz\":\"America/New_York\"}",
129 "properties": {
130 "kind": { "type": "string", "enum": ["cron"] },
131 "expr": { "type": "string", "description": "Standard 5-field cron expression, e.g. '*/5 * * * *'" },
132 "tz": { "type": "string", "description": CRON_TZ_DESCRIPTION }
133 },
134 "required": ["kind", "expr"]
135 },
136 {
137 "type": "object",
138 "description": "One-shot schedule at a specific RFC3339 timestamp with explicit Z or offset. Example: {\"kind\":\"at\",\"at\":\"2025-12-31T23:59:00Z\"}",
139 "properties": {
140 "kind": { "type": "string", "enum": ["at"] },
141 "at": { "type": "string", "description": AT_DESCRIPTION }
142 },
143 "required": ["kind", "at"]
144 },
145 {
146 "type": "object",
147 "description": "Repeating interval schedule in milliseconds. Example: {\"kind\":\"every\",\"every_ms\":3600000} runs every hour.",
148 "properties": {
149 "kind": { "type": "string", "enum": ["every"] },
150 "every_ms": { "type": "integer", "description": "Interval in milliseconds, e.g. 3600000 for every hour" }
151 },
152 "required": ["kind", "every_ms"]
153 }
154 ]
155 },
156 "delivery": {
157 "type": "object",
158 "description": "Delivery config to send job output to a channel after each run. When provided, mode, channel, and to are all expected.",
159 "properties": {
160 "mode": {
161 "type": "string",
162 "enum": ["none", "announce"],
163 "description": "'announce' sends output to the specified channel; 'none' disables delivery"
164 },
165 "channel": {
166 "type": "string",
167 "enum": ["telegram", "discord", "slack", "mattermost", "matrix", "qq", "webhook", "lark", "feishu"],
168 "description": "Channel type to deliver output to"
169 },
170 "to": {
171 "type": "string",
172 "description": "Destination ID: Discord channel ID, Telegram chat ID, Slack channel name, webhook recipient, etc."
173 },
174 "thread_id": {
175 "type": "string",
176 "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`."
177 },
178 "best_effort": {
179 "type": "boolean",
180 "description": "If true, a delivery failure does not fail the job itself. Defaults to true."
181 }
182 }
183 }
184 }
185 },
186 "approved": {
187 "type": "boolean",
188 "description": "Set true to explicitly approve medium/high-risk shell commands in supervised mode",
189 "default": false
190 }
191 },
192 "required": ["job_id", "patch"]
193 })
194 }
195
196 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
197 if !self.config.scheduler.enabled {
198 return Ok(ToolResult {
199 success: false,
200 output: String::new(),
201 error: Some("cron is disabled by config (scheduler.enabled=false)".to_string()),
202 });
203 }
204
205 let job_id = match args.get("job_id").and_then(serde_json::Value::as_str) {
206 Some(v) if !v.trim().is_empty() => v,
207 _ => {
208 return Ok(ToolResult {
209 success: false,
210 output: String::new(),
211 error: Some("Missing 'job_id' parameter".to_string()),
212 });
213 }
214 };
215
216 let patch_val = match args.get("patch") {
217 Some(v) => v.clone(),
218 None => {
219 return Ok(ToolResult {
220 success: false,
221 output: String::new(),
222 error: Some("Missing 'patch' parameter".to_string()),
223 });
224 }
225 };
226
227 let patch = match deserialize_patch_arg(&patch_val) {
228 Ok(patch) => patch,
229 Err(error) => {
230 return Ok(ToolResult {
231 success: false,
232 output: String::new(),
233 error: Some(error),
234 });
235 }
236 };
237 let approved = args
238 .get("approved")
239 .and_then(serde_json::Value::as_bool)
240 .unwrap_or(false);
241
242 if let Some(blocked) = self.enforce_mutation_allowed("cron_update") {
243 return Ok(blocked);
244 }
245
246 match cron::update_shell_job_with_approval(
247 &self.config,
248 &self.agent_alias,
249 job_id,
250 patch,
251 approved,
252 ) {
253 Ok(job) => Ok(ToolResult {
254 success: true,
255 output: serde_json::to_string_pretty(&cron_job_output(&job)?)?,
256 error: None,
257 }),
258 Err(e) => Ok(ToolResult {
259 success: false,
260 output: String::new(),
261 error: Some(e.to_string()),
262 }),
263 }
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::security::AutonomyLevel;
271 use tempfile::TempDir;
272 use zeroclaw_config::schema::Config;
273
274 const TEST_AGENT: &str = "test-agent";
275
276 async fn test_config(tmp: &TempDir) -> Arc<Config> {
277 let mut config = Config {
278 data_dir: tmp.path().join("data"),
279 config_path: tmp.path().join("config.toml"),
280 ..Config::default()
281 };
282 seed_test_agent(&mut config);
283 tokio::fs::create_dir_all(&config.data_dir).await.unwrap();
284 Arc::new(config)
285 }
286
287 fn seed_test_agent(config: &mut Config) {
288 config
289 .risk_profiles
290 .entry(TEST_AGENT.to_string())
291 .or_default();
292 config
293 .runtime_profiles
294 .entry(TEST_AGENT.to_string())
295 .or_default();
296 config
297 .providers
298 .models
299 .ensure("openrouter", TEST_AGENT)
300 .expect("known family");
301 config.agents.entry(TEST_AGENT.to_string()).or_insert(
302 zeroclaw_config::schema::AliasedAgentConfig {
303 model_provider: format!("openrouter.{TEST_AGENT}").into(),
304 risk_profile: TEST_AGENT.to_string(),
305 runtime_profile: TEST_AGENT.to_string(),
306 ..Default::default()
307 },
308 );
309 }
310
311 fn test_security(cfg: &Config) -> Arc<SecurityPolicy> {
312 Arc::new(
313 SecurityPolicy::for_agent(cfg, TEST_AGENT).expect("test-agent has resolvable profiles"),
314 )
315 }
316
317 #[tokio::test]
318 async fn updates_enabled_flag() {
319 let tmp = TempDir::new().unwrap();
320 let cfg = test_config(&tmp).await;
321 let job = cron::add_job(&cfg, TEST_AGENT, "*/5 * * * *", "echo ok").unwrap();
322 let tool = CronUpdateTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
323
324 let result = tool
325 .execute(json!({
326 "job_id": job.id,
327 "patch": { "enabled": false }
328 }))
329 .await
330 .unwrap();
331
332 assert!(result.success, "{:?}", result.error);
333 assert!(result.output.contains("\"enabled\": false"));
334 }
335
336 #[tokio::test]
337 async fn output_includes_timezone_confirmation_fields_for_explicit_cron_timezone() {
338 let tmp = TempDir::new().unwrap();
339 let cfg = test_config(&tmp).await;
340 let job = cron::add_job(&cfg, TEST_AGENT, "*/5 * * * *", "echo ok").unwrap();
341 let tool = CronUpdateTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
342
343 let result = tool
344 .execute(json!({
345 "job_id": job.id,
346 "patch": {
347 "schedule": {
348 "kind": "cron",
349 "expr": "0 9 * * 1-5",
350 "tz": "America/New_York"
351 }
352 }
353 }))
354 .await
355 .unwrap();
356
357 assert!(result.success, "{:?}", result.error);
358 let output: serde_json::Value = serde_json::from_str(&result.output).unwrap();
359 assert_eq!(output["next_run"], output["next_run_utc"]);
360 assert_eq!(output["schedule_timezone"], "America/New_York");
361 assert_eq!(output["timezone_source"], "explicit");
362 assert!(
363 output["next_run_local"]
364 .as_str()
365 .is_some_and(|value| value.contains("T09:00:00")),
366 "next_run_local should display the next run in the explicit schedule timezone: {output}"
367 );
368 }
369
370 #[tokio::test]
371 async fn blocks_disallowed_command_updates() {
372 let tmp = TempDir::new().unwrap();
373 let mut config = Config {
374 data_dir: tmp.path().join("data"),
375 config_path: tmp.path().join("config.toml"),
376 ..Config::default()
377 };
378 seed_test_agent(&mut config);
379 config
380 .risk_profiles
381 .entry(TEST_AGENT.into())
382 .or_default()
383 .allowed_commands = vec!["echo".into()];
384 tokio::fs::create_dir_all(&config.data_dir).await.unwrap();
385 let cfg = Arc::new(config);
386 let job = cron::add_job(&cfg, TEST_AGENT, "*/5 * * * *", "echo ok").unwrap();
387 let tool = CronUpdateTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
388
389 let result = tool
390 .execute(json!({
391 "job_id": job.id,
392 "patch": { "command": "curl https://example.com" }
393 }))
394 .await
395 .unwrap();
396 assert!(!result.success);
397 assert!(result.error.unwrap_or_default().contains("not allowed"));
398 }
399
400 #[tokio::test]
401 async fn blocks_mutation_in_read_only_mode() {
402 let tmp = TempDir::new().unwrap();
403 let mut config = Config {
404 data_dir: tmp.path().join("data"),
405 config_path: tmp.path().join("config.toml"),
406 ..Config::default()
407 };
408 std::fs::create_dir_all(&config.data_dir).unwrap();
409 seed_test_agent(&mut config);
410 let job = cron::add_job(&config, TEST_AGENT, "*/5 * * * *", "echo ok").unwrap();
411 config
412 .risk_profiles
413 .entry(TEST_AGENT.into())
414 .or_default()
415 .level = AutonomyLevel::ReadOnly;
416 let cfg = Arc::new(config);
417 let tool = CronUpdateTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
418
419 let result = tool
420 .execute(json!({
421 "job_id": job.id,
422 "patch": { "enabled": false }
423 }))
424 .await
425 .unwrap();
426 assert!(!result.success);
427 assert!(result.error.unwrap_or_default().contains("read-only"));
428 }
429
430 #[tokio::test]
431 async fn medium_risk_shell_update_requires_approval() {
432 let tmp = TempDir::new().unwrap();
433 let mut config = Config {
434 data_dir: tmp.path().join("data"),
435 config_path: tmp.path().join("config.toml"),
436 ..Config::default()
437 };
438 seed_test_agent(&mut config);
439 config
440 .risk_profiles
441 .entry(TEST_AGENT.into())
442 .or_default()
443 .level = AutonomyLevel::Supervised;
444 config
445 .risk_profiles
446 .entry(TEST_AGENT.into())
447 .or_default()
448 .allowed_commands = vec!["echo".into(), "touch".into()];
449 std::fs::create_dir_all(&config.data_dir).unwrap();
450 seed_test_agent(&mut config);
451 let cfg = Arc::new(config);
452 let job = cron::add_job(&cfg, TEST_AGENT, "*/5 * * * *", "echo ok").unwrap();
453 let tool = CronUpdateTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
454
455 let denied = tool
456 .execute(json!({
457 "job_id": job.id,
458 "patch": { "command": "touch cron-update-approval-test" }
459 }))
460 .await
461 .unwrap();
462 assert!(!denied.success);
463 assert!(
464 denied
465 .error
466 .unwrap_or_default()
467 .contains("explicit approval")
468 );
469
470 let approved = tool
471 .execute(json!({
472 "job_id": job.id,
473 "patch": { "command": "touch cron-update-approval-test" },
474 "approved": true
475 }))
476 .await
477 .unwrap();
478 assert!(approved.success, "{:?}", approved.error);
479 }
480
481 #[tokio::test]
482 async fn rejects_at_timestamp_without_explicit_offset_with_actionable_error() {
483 let tmp = TempDir::new().unwrap();
484 let cfg = test_config(&tmp).await;
485 let job = cron::add_job(&cfg, TEST_AGENT, "*/5 * * * *", "echo ok").unwrap();
486 let tool = CronUpdateTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
487
488 let result = tool
489 .execute(json!({
490 "job_id": job.id,
491 "patch": {
492 "schedule": {
493 "kind": "at",
494 "at": "2026-05-18T09:00:00"
495 }
496 }
497 }))
498 .await
499 .unwrap();
500
501 assert!(!result.success);
502 let error = result.error.unwrap_or_default();
503 assert!(
504 error.contains("RFC3339 timestamp with explicit Z or offset"),
505 "error should explain the explicit offset requirement: {error}"
506 );
507 assert!(error.contains("2026-05-18T09:00:00Z"));
508 assert!(error.contains("2026-05-18T09:00:00-04:00"));
509 }
510
511 #[test]
512 fn patch_schema_covers_all_cronjobpatch_fields_and_schedule_is_oneof() {
513 let tmp = TempDir::new().unwrap();
514 let cfg = Arc::new(Config {
515 data_dir: tmp.path().join("data"),
516 config_path: tmp.path().join("config.toml"),
517 ..Config::default()
518 });
519 let security = Arc::new(SecurityPolicy::from_risk_profile(
520 &zeroclaw_config::schema::RiskProfileConfig::default(),
521 &cfg.data_dir,
522 ));
523 let tool = CronUpdateTool::new(cfg, security, TEST_AGENT);
524 let schema = tool.parameters_schema();
525
526 let top_required = schema["required"].as_array().expect("top-level required");
528 let top_req_strs: Vec<&str> = top_required.iter().filter_map(|v| v.as_str()).collect();
529 assert!(top_req_strs.contains(&"job_id"));
530 assert!(top_req_strs.contains(&"patch"));
531
532 let patch_props = schema["properties"]["patch"]["properties"]
534 .as_object()
535 .expect("patch must have a properties object");
536 for field in &[
537 "name",
538 "enabled",
539 "command",
540 "prompt",
541 "model",
542 "allowed_tools",
543 "session_target",
544 "delete_after_run",
545 "schedule",
546 "delivery",
547 ] {
548 assert!(
549 patch_props.contains_key(*field),
550 "patch schema missing field: {field}"
551 );
552 }
553
554 let one_of = schema["properties"]["patch"]["properties"]["schedule"]["oneOf"]
556 .as_array()
557 .expect("patch.schedule.oneOf must be an array");
558 assert_eq!(one_of.len(), 3, "expected cron, at, and every variants");
559
560 let kinds: Vec<&str> = one_of
561 .iter()
562 .filter_map(|v| v["properties"]["kind"]["enum"][0].as_str())
563 .collect();
564 assert!(kinds.contains(&"cron"), "missing cron variant");
565 assert!(kinds.contains(&"at"), "missing at variant");
566 assert!(kinds.contains(&"every"), "missing every variant");
567
568 for variant in one_of {
570 let kind = variant["properties"]["kind"]["enum"][0]
571 .as_str()
572 .expect("variant kind");
573 let req: Vec<&str> = variant["required"]
574 .as_array()
575 .unwrap_or_else(|| panic!("{kind} variant must have required"))
576 .iter()
577 .filter_map(|v| v.as_str())
578 .collect();
579 assert!(
580 req.contains(&"kind"),
581 "{kind} variant missing 'kind' in required"
582 );
583 match kind {
584 "cron" => assert!(req.contains(&"expr"), "cron variant missing 'expr'"),
585 "at" => assert!(req.contains(&"at"), "at variant missing 'at'"),
586 "every" => {
587 assert!(
588 req.contains(&"every_ms"),
589 "every variant missing 'every_ms'"
590 );
591 assert_eq!(
592 variant["properties"]["every_ms"]["type"].as_str(),
593 Some("integer"),
594 "every_ms must be typed as integer"
595 );
596 }
597 _ => panic!("unexpected schedule kind: {kind}"),
598 }
599 }
600
601 let cron_variant = one_of
602 .iter()
603 .find(|variant| variant["properties"]["kind"]["enum"][0] == "cron")
604 .expect("cron variant");
605 let cron_tz_description = cron_variant["properties"]["tz"]["description"]
606 .as_str()
607 .expect("cron tz description");
608 assert!(
609 cron_tz_description.contains("runtime local timezone"),
610 "cron tz description must match scheduler fallback: {cron_tz_description}"
611 );
612 assert!(
613 cron_tz_description.contains("explicit IANA timezone"),
614 "cron tz description should recommend explicit IANA timezones: {cron_tz_description}"
615 );
616 assert!(
617 !cron_tz_description.contains("Defaults to UTC"),
618 "cron tz description must not claim a UTC default"
619 );
620
621 let at_variant = one_of
622 .iter()
623 .find(|variant| variant["properties"]["kind"]["enum"][0] == "at")
624 .expect("at variant");
625 let at_description = at_variant["properties"]["at"]["description"]
626 .as_str()
627 .expect("at description");
628 assert!(
629 at_description.contains("RFC3339 timestamp with explicit Z or offset"),
630 "at description should require explicit Z or offset: {at_description}"
631 );
632
633 let channel_enum = schema["properties"]["patch"]["properties"]["delivery"]["properties"]
635 ["channel"]["enum"]
636 .as_array()
637 .expect("patch.delivery.channel must have an enum");
638 let channel_strs: Vec<&str> = channel_enum.iter().filter_map(|v| v.as_str()).collect();
639 for ch in &[
640 "telegram",
641 "discord",
642 "slack",
643 "mattermost",
644 "matrix",
645 "qq",
646 "webhook",
647 ] {
648 assert!(channel_strs.contains(ch), "delivery.channel missing: {ch}");
649 }
650
651 let delivery_props = schema["properties"]["patch"]["properties"]["delivery"]["properties"]
654 .as_object()
655 .expect("patch.delivery must have properties");
656 assert!(
657 delivery_props.contains_key("thread_id"),
658 "patch.delivery missing thread_id"
659 );
660 }
661
662 #[tokio::test]
663 async fn blocks_update_when_rate_limited() {
664 let tmp = TempDir::new().unwrap();
665 let mut config = Config {
666 data_dir: tmp.path().join("data"),
667 config_path: tmp.path().join("config.toml"),
668 ..Config::default()
669 };
670 seed_test_agent(&mut config);
671 config
672 .risk_profiles
673 .entry(TEST_AGENT.into())
674 .or_default()
675 .level = AutonomyLevel::Full;
676 config
677 .runtime_profiles
678 .entry(TEST_AGENT.into())
679 .or_default()
680 .max_actions_per_hour = 0;
681 std::fs::create_dir_all(&config.data_dir).unwrap();
682 seed_test_agent(&mut config);
683 let cfg = Arc::new(config);
684 let job = cron::add_job(&cfg, TEST_AGENT, "*/5 * * * *", "echo ok").unwrap();
685 let tool = CronUpdateTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
686
687 let result = tool
688 .execute(json!({
689 "job_id": job.id,
690 "patch": { "enabled": false }
691 }))
692 .await
693 .unwrap();
694 assert!(!result.success);
695 assert!(
696 result
697 .error
698 .unwrap_or_default()
699 .contains("Rate limit exceeded")
700 );
701 assert!(cron::get_job(&cfg, &job.id).unwrap().enabled);
702 }
703
704 #[tokio::test]
705 async fn empty_allowed_tools_patch_stored_as_none() {
706 let tmp = TempDir::new().unwrap();
707 let cfg = test_config(&tmp).await;
708 let job = cron::add_agent_job(
709 &cfg,
710 TEST_AGENT,
711 None,
712 crate::cron::Schedule::Cron {
713 expr: "*/5 * * * *".into(),
714 tz: None,
715 },
716 "check status",
717 crate::cron::SessionTarget::Isolated,
718 None,
719 None,
720 false,
721 Some(vec!["file_read".into()]),
722 )
723 .unwrap();
724 let tool = CronUpdateTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
725
726 let result = tool
727 .execute(json!({
728 "job_id": job.id,
729 "patch": { "allowed_tools": [] }
730 }))
731 .await
732 .unwrap();
733
734 assert!(result.success, "{:?}", result.error);
735 assert_eq!(
736 cron::get_job(&cfg, &job.id).unwrap().allowed_tools,
737 None,
738 "empty allowed_tools patch should clear to None"
739 );
740 }
741
742 #[tokio::test]
743 async fn updates_agent_allowed_tools() {
744 let tmp = TempDir::new().unwrap();
745 let cfg = test_config(&tmp).await;
746 let job = cron::add_agent_job(
747 &cfg,
748 TEST_AGENT,
749 None,
750 crate::cron::Schedule::Cron {
751 expr: "*/5 * * * *".into(),
752 tz: None,
753 },
754 "check status",
755 crate::cron::SessionTarget::Isolated,
756 None,
757 None,
758 false,
759 None,
760 )
761 .unwrap();
762 let tool = CronUpdateTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
763
764 let result = tool
765 .execute(json!({
766 "job_id": job.id,
767 "patch": { "allowed_tools": ["file_read", "web_search"] }
768 }))
769 .await
770 .unwrap();
771
772 assert!(result.success, "{:?}", result.error);
773 assert_eq!(
774 cron::get_job(&cfg, &job.id).unwrap().allowed_tools,
775 Some(vec!["file_read".into(), "web_search".into()])
776 );
777 }
778}