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": cron::CRON_DELIVERY_SCHEMA_CHANNELS,
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        assert_eq!(channel_strs.as_slice(), cron::CRON_DELIVERY_SCHEMA_CHANNELS);
640        assert!(channel_strs.contains(&"dingtalk"));
641
642        // patch.delivery exposes thread_id so the webhook channel can route callbacks
643        // back to the originating conversation.
644        let delivery_props = schema["properties"]["patch"]["properties"]["delivery"]["properties"]
645            .as_object()
646            .expect("patch.delivery must have properties");
647        assert!(
648            delivery_props.contains_key("thread_id"),
649            "patch.delivery missing thread_id"
650        );
651    }
652
653    #[test]
654    fn add_and_update_delivery_channel_schemas_match() {
655        let tmp = TempDir::new().unwrap();
656        let cfg = Arc::new(Config {
657            data_dir: tmp.path().join("data"),
658            config_path: tmp.path().join("config.toml"),
659            ..Config::default()
660        });
661        let security = Arc::new(SecurityPolicy::from_risk_profile(
662            &zeroclaw_config::schema::RiskProfileConfig::default(),
663            &cfg.data_dir,
664        ));
665        let add_tool = crate::tools::cron_add::CronAddTool::new(
666            Arc::clone(&cfg),
667            Arc::clone(&security),
668            TEST_AGENT,
669        );
670        let update_tool = CronUpdateTool::new(cfg, security, TEST_AGENT);
671        let add_schema = add_tool.parameters_schema();
672        let update_schema = update_tool.parameters_schema();
673
674        let add_channels: Vec<&str> = add_schema["properties"]["delivery"]["properties"]["channel"]
675            ["enum"]
676            .as_array()
677            .expect("cron_add delivery.channel must have an enum")
678            .iter()
679            .filter_map(|value| value.as_str())
680            .collect();
681        let update_channels: Vec<&str> =
682            update_schema["properties"]["patch"]["properties"]["delivery"]["properties"]["channel"]
683                ["enum"]
684                .as_array()
685                .expect("cron_update patch.delivery.channel must have an enum")
686                .iter()
687                .filter_map(|value| value.as_str())
688                .collect();
689
690        assert_eq!(add_channels, update_channels);
691        assert_eq!(add_channels.as_slice(), cron::CRON_DELIVERY_SCHEMA_CHANNELS);
692        assert!(add_channels.contains(&"dingtalk"));
693    }
694
695    #[tokio::test]
696    async fn blocks_update_when_rate_limited() {
697        let tmp = TempDir::new().unwrap();
698        let mut config = Config {
699            data_dir: tmp.path().join("data"),
700            config_path: tmp.path().join("config.toml"),
701            ..Config::default()
702        };
703        seed_test_agent(&mut config);
704        config
705            .risk_profiles
706            .entry(TEST_AGENT.into())
707            .or_default()
708            .level = AutonomyLevel::Full;
709        config
710            .runtime_profiles
711            .entry(TEST_AGENT.into())
712            .or_default()
713            .max_actions_per_hour = 0;
714        std::fs::create_dir_all(&config.data_dir).unwrap();
715        seed_test_agent(&mut config);
716        let cfg = Arc::new(config);
717        let job = cron::add_job(&cfg, TEST_AGENT, "*/5 * * * *", "echo ok").unwrap();
718        let tool = CronUpdateTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
719
720        let result = tool
721            .execute(json!({
722                "job_id": job.id,
723                "patch": { "enabled": false }
724            }))
725            .await
726            .unwrap();
727        assert!(!result.success);
728        assert!(
729            result
730                .error
731                .unwrap_or_default()
732                .contains("Rate limit exceeded")
733        );
734        assert!(cron::get_job(&cfg, &job.id).unwrap().enabled);
735    }
736
737    #[tokio::test]
738    async fn empty_allowed_tools_patch_stored_as_none() {
739        let tmp = TempDir::new().unwrap();
740        let cfg = test_config(&tmp).await;
741        let job = cron::add_agent_job(
742            &cfg,
743            TEST_AGENT,
744            None,
745            crate::cron::Schedule::Cron {
746                expr: "*/5 * * * *".into(),
747                tz: None,
748            },
749            "check status",
750            crate::cron::SessionTarget::Isolated,
751            None,
752            None,
753            false,
754            Some(vec!["file_read".into()]),
755        )
756        .unwrap();
757        let tool = CronUpdateTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
758
759        let result = tool
760            .execute(json!({
761                "job_id": job.id,
762                "patch": { "allowed_tools": [] }
763            }))
764            .await
765            .unwrap();
766
767        assert!(result.success, "{:?}", result.error);
768        assert_eq!(
769            cron::get_job(&cfg, &job.id).unwrap().allowed_tools,
770            None,
771            "empty allowed_tools patch should clear to None"
772        );
773    }
774
775    #[tokio::test]
776    async fn updates_agent_allowed_tools() {
777        let tmp = TempDir::new().unwrap();
778        let cfg = test_config(&tmp).await;
779        let job = cron::add_agent_job(
780            &cfg,
781            TEST_AGENT,
782            None,
783            crate::cron::Schedule::Cron {
784                expr: "*/5 * * * *".into(),
785                tz: None,
786            },
787            "check status",
788            crate::cron::SessionTarget::Isolated,
789            None,
790            None,
791            false,
792            None,
793        )
794        .unwrap();
795        let tool = CronUpdateTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
796
797        let result = tool
798            .execute(json!({
799                "job_id": job.id,
800                "patch": { "allowed_tools": ["file_read", "web_search"] }
801            }))
802            .await
803            .unwrap();
804
805        assert!(result.success, "{:?}", result.error);
806        assert_eq!(
807            cron::get_job(&cfg, &job.id).unwrap().allowed_tools,
808            Some(vec!["file_read".into(), "web_search".into()])
809        );
810    }
811}