zeroclaw_runtime/tools/
cron_remove.rs1use crate::cron;
2use crate::security::SecurityPolicy;
3use async_trait::async_trait;
4use serde_json::json;
5use std::sync::Arc;
6use zeroclaw_api::tool::{Tool, ToolResult};
7use zeroclaw_config::schema::Config;
8
9pub struct CronRemoveTool {
10 config: Arc<Config>,
11 security: Arc<SecurityPolicy>,
12}
13
14impl CronRemoveTool {
15 pub fn new(config: Arc<Config>, security: Arc<SecurityPolicy>) -> Self {
16 Self { config, security }
17 }
18
19 fn enforce_mutation_allowed(&self, action: &str) -> Option<ToolResult> {
20 if !self.security.can_act() {
21 return Some(ToolResult {
22 success: false,
23 output: String::new(),
24 error: Some(format!(
25 "Security policy: read-only mode, cannot perform '{action}'"
26 )),
27 });
28 }
29
30 if self.security.is_rate_limited() {
31 return Some(ToolResult {
32 success: false,
33 output: String::new(),
34 error: Some("Rate limit exceeded: too many actions in the last hour".to_string()),
35 });
36 }
37
38 if !self.security.record_action() {
39 return Some(ToolResult {
40 success: false,
41 output: String::new(),
42 error: Some("Rate limit exceeded: action budget exhausted".to_string()),
43 });
44 }
45
46 None
47 }
48}
49
50#[async_trait]
51impl Tool for CronRemoveTool {
52 fn name(&self) -> &str {
53 "cron_remove"
54 }
55
56 fn description(&self) -> &str {
57 "Remove a cron job by id"
58 }
59
60 fn parameters_schema(&self) -> serde_json::Value {
61 json!({
62 "type": "object",
63 "properties": {
64 "job_id": { "type": "string" }
65 },
66 "required": ["job_id"]
67 })
68 }
69
70 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
71 if !self.config.scheduler.enabled {
72 return Ok(ToolResult {
73 success: false,
74 output: String::new(),
75 error: Some("cron is disabled by config (scheduler.enabled=false)".to_string()),
76 });
77 }
78
79 let job_id = match args.get("job_id").and_then(serde_json::Value::as_str) {
80 Some(v) if !v.trim().is_empty() => v,
81 _ => {
82 return Ok(ToolResult {
83 success: false,
84 output: String::new(),
85 error: Some("Missing 'job_id' parameter".to_string()),
86 });
87 }
88 };
89
90 if let Some(blocked) = self.enforce_mutation_allowed("cron_remove") {
91 return Ok(blocked);
92 }
93
94 match cron::remove_job(&self.config, job_id) {
95 Ok(()) => Ok(ToolResult {
96 success: true,
97 output: format!("Removed cron job {job_id}"),
98 error: None,
99 }),
100 Err(e) => Ok(ToolResult {
101 success: false,
102 output: String::new(),
103 error: Some(e.to_string()),
104 }),
105 }
106 }
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 use crate::security::AutonomyLevel;
113 use tempfile::TempDir;
114 use zeroclaw_config::schema::Config;
115
116 const TEST_AGENT: &str = "test-agent";
117
118 async fn test_config(tmp: &TempDir) -> Arc<Config> {
119 let mut config = Config {
120 data_dir: tmp.path().join("data"),
121 config_path: tmp.path().join("config.toml"),
122 ..Config::default()
123 };
124 seed_test_agent(&mut config);
125 tokio::fs::create_dir_all(&config.data_dir).await.unwrap();
126 Arc::new(config)
127 }
128
129 fn seed_test_agent(config: &mut Config) {
130 config
131 .risk_profiles
132 .entry(TEST_AGENT.to_string())
133 .or_default();
134 config
135 .runtime_profiles
136 .entry(TEST_AGENT.to_string())
137 .or_default();
138 config
139 .providers
140 .models
141 .ensure("openrouter", TEST_AGENT)
142 .expect("known family");
143 config.agents.entry(TEST_AGENT.to_string()).or_insert(
144 zeroclaw_config::schema::AliasedAgentConfig {
145 model_provider: format!("openrouter.{TEST_AGENT}").into(),
146 risk_profile: TEST_AGENT.to_string(),
147 runtime_profile: TEST_AGENT.to_string(),
148 ..Default::default()
149 },
150 );
151 }
152
153 fn test_security(cfg: &Config) -> Arc<SecurityPolicy> {
154 Arc::new(
155 SecurityPolicy::for_agent(cfg, TEST_AGENT).expect("test-agent has resolvable profiles"),
156 )
157 }
158
159 #[tokio::test]
160 async fn removes_existing_job() {
161 let tmp = TempDir::new().unwrap();
162 let cfg = test_config(&tmp).await;
163 let job = cron::add_job(&cfg, TEST_AGENT, "*/5 * * * *", "echo ok").unwrap();
164 let tool = CronRemoveTool::new(cfg.clone(), test_security(&cfg));
165
166 let result = tool.execute(json!({"job_id": job.id})).await.unwrap();
167 assert!(result.success);
168 assert!(cron::list_jobs(&cfg).unwrap().is_empty());
169 }
170
171 #[tokio::test]
172 async fn errors_when_job_id_missing() {
173 let tmp = TempDir::new().unwrap();
174 let cfg = test_config(&tmp).await;
175 let tool = CronRemoveTool::new(cfg.clone(), test_security(&cfg));
176
177 let result = tool.execute(json!({})).await.unwrap();
178 assert!(!result.success);
179 assert!(
180 result
181 .error
182 .unwrap_or_default()
183 .contains("Missing 'job_id'")
184 );
185 }
186
187 #[tokio::test]
188 async fn blocks_remove_in_read_only_mode() {
189 let tmp = TempDir::new().unwrap();
190 let mut config = Config {
191 data_dir: tmp.path().join("data"),
192 config_path: tmp.path().join("config.toml"),
193 ..Config::default()
194 };
195 std::fs::create_dir_all(&config.data_dir).unwrap();
196 seed_test_agent(&mut config);
197 let job = cron::add_job(&config, TEST_AGENT, "*/5 * * * *", "echo ok").unwrap();
198 config
199 .risk_profiles
200 .entry(TEST_AGENT.into())
201 .or_default()
202 .level = AutonomyLevel::ReadOnly;
203 let cfg = Arc::new(config);
204 let tool = CronRemoveTool::new(cfg.clone(), test_security(&cfg));
205
206 let result = tool.execute(json!({"job_id": job.id})).await.unwrap();
207 assert!(!result.success);
208 assert!(result.error.unwrap_or_default().contains("read-only"));
209 }
210
211 #[tokio::test]
212 async fn blocks_remove_when_rate_limited() {
213 let tmp = TempDir::new().unwrap();
214 let mut config = Config {
215 data_dir: tmp.path().join("data"),
216 config_path: tmp.path().join("config.toml"),
217 ..Config::default()
218 };
219 config
220 .risk_profiles
221 .entry(TEST_AGENT.into())
222 .or_default()
223 .level = AutonomyLevel::Full;
224 config
225 .runtime_profiles
226 .entry(TEST_AGENT.into())
227 .or_default()
228 .max_actions_per_hour = 0;
229 std::fs::create_dir_all(&config.data_dir).unwrap();
230 seed_test_agent(&mut config);
231 let cfg = Arc::new(config);
232 let job = cron::add_job(&cfg, TEST_AGENT, "*/5 * * * *", "echo ok").unwrap();
233 let tool = CronRemoveTool::new(cfg.clone(), test_security(&cfg));
234
235 let result = tool.execute(json!({"job_id": job.id})).await.unwrap();
236 assert!(!result.success);
237 assert!(
238 result
239 .error
240 .unwrap_or_default()
241 .contains("Rate limit exceeded")
242 );
243 assert_eq!(cron::list_jobs(&cfg).unwrap().len(), 1);
244 }
245}