Skip to main content

zeroclaw_runtime/tools/
cron_update.rs

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    /// Owning agent — risk profile gate for command updates.
16    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                        // NOTE: oneOf is correct for OpenAI-compatible APIs (including OpenRouter).
120                        // Gemini does not support oneOf in tool schemas; if Gemini native tool calling
121                        // is ever wired up, SchemaCleanr::clean_for_gemini must be applied before
122                        // tool specs are sent. See src/tools/schema.rs.
123                        "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        // Top-level: job_id and patch are required
527        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        // patch exposes all CronJobPatch fields
533        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        // patch.schedule is a oneOf with exactly 3 variants: cron, at, every
555        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        // Each variant declares its required fields and every_ms is typed integer
569        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        // patch.delivery.channel enum covers all supported channels
634        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        // patch.delivery exposes thread_id so the webhook channel can route callbacks
652        // back to the originating conversation.
653        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}