zeroclaw/src/cron/mod.rs
Argenis c41009d29f
fix(cron): persist allowed_tools for agent jobs (#3993)
Persist allowed_tools in cron_jobs table, threading it through CLI add/update and cron_add/cron_update tool APIs. Add regression coverage for store, tool, and CLI roundtrip paths.

Fixups over original PR #3929: add allowed_tools to all_overdue_jobs SELECT (merge gap), resolve merge conflicts.

Closes #3920
Supersedes #3929
2026-03-24 15:26:29 +03:00

980 lines
31 KiB
Rust

use crate::config::Config;
use crate::security::SecurityPolicy;
use anyhow::{anyhow, bail, Result};
mod schedule;
mod store;
mod types;
pub mod scheduler;
#[allow(unused_imports)]
pub use schedule::{
next_run_for_schedule, normalize_expression, schedule_cron_expression, validate_schedule,
};
#[allow(unused_imports)]
pub use store::{
add_agent_job, all_overdue_jobs, due_jobs, get_job, list_jobs, list_runs, record_last_run,
record_run, remove_job, reschedule_after_run, update_job,
};
pub use types::{
deserialize_maybe_stringified, CronJob, CronJobPatch, CronRun, DeliveryConfig, JobType,
Schedule, SessionTarget,
};
/// Validate a shell command against the full security policy (allowlist + risk gate).
///
/// Returns `Ok(())` if the command passes all checks, or an error describing
/// why it was blocked.
pub fn validate_shell_command(config: &Config, command: &str, approved: bool) -> Result<()> {
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
validate_shell_command_with_security(&security, command, approved)
}
/// Validate a shell command using an existing `SecurityPolicy` instance.
///
/// Preferred when the caller already holds a `SecurityPolicy` (e.g. scheduler).
pub(crate) fn validate_shell_command_with_security(
security: &SecurityPolicy,
command: &str,
approved: bool,
) -> Result<()> {
security
.validate_command_execution(command, approved)
.map(|_| ())
.map_err(|reason| anyhow!("blocked by security policy: {reason}"))
}
/// Create a validated shell job, enforcing security policy before persistence.
///
/// All entrypoints that create shell cron jobs should route through this
/// function to guarantee consistent policy enforcement.
pub fn add_shell_job_with_approval(
config: &Config,
name: Option<String>,
schedule: Schedule,
command: &str,
approved: bool,
) -> Result<CronJob> {
validate_shell_command(config, command, approved)?;
store::add_shell_job(config, name, schedule, command)
}
/// Update a shell job's command with security validation.
///
/// Validates the new command (if changed) before persisting.
pub fn update_shell_job_with_approval(
config: &Config,
job_id: &str,
patch: CronJobPatch,
approved: bool,
) -> Result<CronJob> {
if let Some(command) = patch.command.as_deref() {
validate_shell_command(config, command, approved)?;
}
update_job(config, job_id, patch)
}
/// Create a one-shot validated shell job from a delay string (e.g. "30m").
pub fn add_once_validated(
config: &Config,
delay: &str,
command: &str,
approved: bool,
) -> Result<CronJob> {
let duration = parse_delay(delay)?;
let at = chrono::Utc::now() + duration;
add_once_at_validated(config, at, command, approved)
}
/// Create a one-shot validated shell job at an absolute timestamp.
pub fn add_once_at_validated(
config: &Config,
at: chrono::DateTime<chrono::Utc>,
command: &str,
approved: bool,
) -> Result<CronJob> {
let schedule = Schedule::At { at };
add_shell_job_with_approval(config, None, schedule, command, approved)
}
// Convenience wrappers for CLI paths (default approved=false).
pub(crate) fn add_shell_job(
config: &Config,
name: Option<String>,
schedule: Schedule,
command: &str,
) -> Result<CronJob> {
add_shell_job_with_approval(config, name, schedule, command, false)
}
pub(crate) fn add_job(config: &Config, expression: &str, command: &str) -> Result<CronJob> {
let schedule = Schedule::Cron {
expr: expression.to_string(),
tz: None,
};
add_shell_job(config, None, schedule, command)
}
#[allow(clippy::needless_pass_by_value)]
pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<()> {
match command {
crate::CronCommands::List => {
let jobs = list_jobs(config)?;
if jobs.is_empty() {
println!("No scheduled tasks yet.");
println!("\nUsage:");
println!(" zeroclaw cron add '0 9 * * *' 'agent -m \"Good morning!\"'");
return Ok(());
}
println!("🕒 Scheduled jobs ({}):", jobs.len());
for job in jobs {
let last_run = job
.last_run
.map_or_else(|| "never".into(), |d| d.to_rfc3339());
let last_status = job.last_status.unwrap_or_else(|| "n/a".into());
println!(
"- {} | {:?} | next={} | last={} ({})",
job.id,
job.schedule,
job.next_run.to_rfc3339(),
last_run,
last_status,
);
if !job.command.is_empty() {
println!(" cmd: {}", job.command);
}
if let Some(prompt) = &job.prompt {
println!(" prompt: {prompt}");
}
}
Ok(())
}
crate::CronCommands::Add {
expression,
tz,
agent,
allowed_tools,
command,
} => {
let schedule = Schedule::Cron {
expr: expression,
tz,
};
if agent {
let job = add_agent_job(
config,
None,
schedule,
&command,
SessionTarget::Isolated,
None,
None,
false,
if allowed_tools.is_empty() {
None
} else {
Some(allowed_tools)
},
)?;
println!("✅ Added agent cron job {}", job.id);
println!(" Expr : {}", job.expression);
println!(" Next : {}", job.next_run.to_rfc3339());
println!(" Prompt: {}", job.prompt.as_deref().unwrap_or_default());
} else {
if !allowed_tools.is_empty() {
bail!("--allowed-tool is only supported with --agent cron jobs");
}
let job = add_shell_job(config, None, schedule, &command)?;
println!("✅ Added cron job {}", job.id);
println!(" Expr: {}", job.expression);
println!(" Next: {}", job.next_run.to_rfc3339());
println!(" Cmd : {}", job.command);
}
Ok(())
}
crate::CronCommands::AddAt {
at,
agent,
allowed_tools,
command,
} => {
let at = chrono::DateTime::parse_from_rfc3339(&at)
.map_err(|e| anyhow::anyhow!("Invalid RFC3339 timestamp for --at: {e}"))?
.with_timezone(&chrono::Utc);
let schedule = Schedule::At { at };
if agent {
let job = add_agent_job(
config,
None,
schedule,
&command,
SessionTarget::Isolated,
None,
None,
true,
if allowed_tools.is_empty() {
None
} else {
Some(allowed_tools)
},
)?;
println!("✅ Added one-shot agent cron job {}", job.id);
println!(" At : {}", job.next_run.to_rfc3339());
println!(" Prompt: {}", job.prompt.as_deref().unwrap_or_default());
} else {
if !allowed_tools.is_empty() {
bail!("--allowed-tool is only supported with --agent cron jobs");
}
let job = add_shell_job(config, None, schedule, &command)?;
println!("✅ Added one-shot cron job {}", job.id);
println!(" At : {}", job.next_run.to_rfc3339());
println!(" Cmd : {}", job.command);
}
Ok(())
}
crate::CronCommands::AddEvery {
every_ms,
agent,
allowed_tools,
command,
} => {
let schedule = Schedule::Every { every_ms };
if agent {
let job = add_agent_job(
config,
None,
schedule,
&command,
SessionTarget::Isolated,
None,
None,
false,
if allowed_tools.is_empty() {
None
} else {
Some(allowed_tools)
},
)?;
println!("✅ Added interval agent cron job {}", job.id);
println!(" Every(ms): {every_ms}");
println!(" Next : {}", job.next_run.to_rfc3339());
println!(" Prompt : {}", job.prompt.as_deref().unwrap_or_default());
} else {
if !allowed_tools.is_empty() {
bail!("--allowed-tool is only supported with --agent cron jobs");
}
let job = add_shell_job(config, None, schedule, &command)?;
println!("✅ Added interval cron job {}", job.id);
println!(" Every(ms): {every_ms}");
println!(" Next : {}", job.next_run.to_rfc3339());
println!(" Cmd : {}", job.command);
}
Ok(())
}
crate::CronCommands::Once {
delay,
agent,
allowed_tools,
command,
} => {
if agent {
let duration = parse_delay(&delay)?;
let at = chrono::Utc::now() + duration;
let schedule = Schedule::At { at };
let job = add_agent_job(
config,
None,
schedule,
&command,
SessionTarget::Isolated,
None,
None,
true,
if allowed_tools.is_empty() {
None
} else {
Some(allowed_tools)
},
)?;
println!("✅ Added one-shot agent cron job {}", job.id);
println!(" At : {}", job.next_run.to_rfc3339());
println!(" Prompt: {}", job.prompt.as_deref().unwrap_or_default());
} else {
if !allowed_tools.is_empty() {
bail!("--allowed-tool is only supported with --agent cron jobs");
}
let job = add_once(config, &delay, &command)?;
println!("✅ Added one-shot cron job {}", job.id);
println!(" At : {}", job.next_run.to_rfc3339());
println!(" Cmd : {}", job.command);
}
Ok(())
}
crate::CronCommands::Update {
id,
expression,
tz,
command,
name,
allowed_tools,
} => {
if expression.is_none()
&& tz.is_none()
&& command.is_none()
&& name.is_none()
&& allowed_tools.is_empty()
{
bail!(
"At least one of --expression, --tz, --command, --name, or --allowed-tool must be provided"
);
}
let existing = if expression.is_some() || tz.is_some() || !allowed_tools.is_empty() {
Some(get_job(config, &id)?)
} else {
None
};
// Merge expression/tz with the existing schedule so that
// --tz alone updates the timezone and --expression alone
// preserves the existing timezone.
let schedule = if expression.is_some() || tz.is_some() {
let existing = existing
.as_ref()
.expect("existing job must be loaded when updating schedule");
let (existing_expr, existing_tz) = match &existing.schedule {
Schedule::Cron {
expr,
tz: existing_tz,
} => (expr.clone(), existing_tz.clone()),
_ => bail!("Cannot update expression/tz on a non-cron schedule"),
};
Some(Schedule::Cron {
expr: expression.unwrap_or(existing_expr),
tz: tz.or(existing_tz),
})
} else {
None
};
if !allowed_tools.is_empty() {
let existing = existing
.as_ref()
.expect("existing job must be loaded when updating allowed tools");
if existing.job_type != JobType::Agent {
bail!("--allowed-tool is only supported for agent cron jobs");
}
}
let patch = CronJobPatch {
schedule,
command,
name,
allowed_tools: if allowed_tools.is_empty() {
None
} else {
Some(allowed_tools)
},
..CronJobPatch::default()
};
let job = update_shell_job_with_approval(config, &id, patch, false)?;
println!("\u{2705} Updated cron job {}", job.id);
println!(" Expr: {}", job.expression);
println!(" Next: {}", job.next_run.to_rfc3339());
println!(" Cmd : {}", job.command);
Ok(())
}
crate::CronCommands::Remove { id } => remove_job(config, &id),
crate::CronCommands::Pause { id } => {
pause_job(config, &id)?;
println!("⏸️ Paused cron job {id}");
Ok(())
}
crate::CronCommands::Resume { id } => {
resume_job(config, &id)?;
println!("▶️ Resumed cron job {id}");
Ok(())
}
}
}
pub(crate) fn add_once(config: &Config, delay: &str, command: &str) -> Result<CronJob> {
add_once_validated(config, delay, command, false)
}
pub(crate) fn add_once_at(
config: &Config,
at: chrono::DateTime<chrono::Utc>,
command: &str,
) -> Result<CronJob> {
add_once_at_validated(config, at, command, false)
}
pub fn pause_job(config: &Config, id: &str) -> Result<CronJob> {
update_job(
config,
id,
CronJobPatch {
enabled: Some(false),
..CronJobPatch::default()
},
)
}
pub fn resume_job(config: &Config, id: &str) -> Result<CronJob> {
update_job(
config,
id,
CronJobPatch {
enabled: Some(true),
..CronJobPatch::default()
},
)
}
fn parse_delay(input: &str) -> Result<chrono::Duration> {
let input = input.trim();
if input.is_empty() {
anyhow::bail!("delay must not be empty");
}
let split = input
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(input.len());
let (num, unit) = input.split_at(split);
let amount: i64 = num.parse()?;
let unit = if unit.is_empty() { "m" } else { unit };
let duration = match unit {
"s" => chrono::Duration::seconds(amount),
"m" => chrono::Duration::minutes(amount),
"h" => chrono::Duration::hours(amount),
"d" => chrono::Duration::days(amount),
_ => anyhow::bail!("unsupported delay unit '{unit}', use s/m/h/d"),
};
Ok(duration)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_config(tmp: &TempDir) -> Config {
let config = Config {
workspace_dir: tmp.path().join("workspace"),
config_path: tmp.path().join("config.toml"),
..Config::default()
};
std::fs::create_dir_all(&config.workspace_dir).unwrap();
config
}
fn make_job(config: &Config, expr: &str, tz: Option<&str>, cmd: &str) -> CronJob {
add_shell_job(
config,
None,
Schedule::Cron {
expr: expr.into(),
tz: tz.map(Into::into),
},
cmd,
)
.unwrap()
}
fn run_update(
config: &Config,
id: &str,
expression: Option<&str>,
tz: Option<&str>,
command: Option<&str>,
name: Option<&str>,
) -> Result<()> {
handle_command(
crate::CronCommands::Update {
id: id.into(),
expression: expression.map(Into::into),
tz: tz.map(Into::into),
command: command.map(Into::into),
name: name.map(Into::into),
allowed_tools: vec![],
},
config,
)
}
#[test]
fn update_changes_command_via_handler() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = make_job(&config, "*/5 * * * *", None, "echo original");
run_update(&config, &job.id, None, None, Some("echo updated"), None).unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(updated.command, "echo updated");
assert_eq!(updated.id, job.id);
}
#[test]
fn update_changes_expression_via_handler() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = make_job(&config, "*/5 * * * *", None, "echo test");
run_update(&config, &job.id, Some("0 9 * * *"), None, None, None).unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(updated.expression, "0 9 * * *");
}
#[test]
fn update_changes_name_via_handler() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = make_job(&config, "*/5 * * * *", None, "echo test");
run_update(&config, &job.id, None, None, None, Some("new-name")).unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(updated.name.as_deref(), Some("new-name"));
}
#[test]
fn update_tz_alone_sets_timezone() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = make_job(&config, "*/5 * * * *", None, "echo test");
run_update(
&config,
&job.id,
None,
Some("America/Los_Angeles"),
None,
None,
)
.unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(
updated.schedule,
Schedule::Cron {
expr: "*/5 * * * *".into(),
tz: Some("America/Los_Angeles".into()),
}
);
}
#[test]
fn update_expression_preserves_existing_tz() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = make_job(
&config,
"*/5 * * * *",
Some("America/Los_Angeles"),
"echo test",
);
run_update(&config, &job.id, Some("0 9 * * *"), None, None, None).unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(
updated.schedule,
Schedule::Cron {
expr: "0 9 * * *".into(),
tz: Some("America/Los_Angeles".into()),
}
);
}
#[test]
fn update_preserves_unchanged_fields() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_shell_job(
&config,
Some("original-name".into()),
Schedule::Cron {
expr: "*/5 * * * *".into(),
tz: None,
},
"echo original",
)
.unwrap();
run_update(&config, &job.id, None, None, Some("echo changed"), None).unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(updated.command, "echo changed");
assert_eq!(updated.name.as_deref(), Some("original-name"));
assert_eq!(updated.expression, "*/5 * * * *");
}
#[test]
fn update_no_flags_fails() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = make_job(&config, "*/5 * * * *", None, "echo test");
let result = run_update(&config, &job.id, None, None, None, None);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("At least one of"));
}
#[test]
fn update_nonexistent_job_fails() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let result = run_update(
&config,
"nonexistent-id",
None,
None,
Some("echo test"),
None,
);
assert!(result.is_err());
}
#[test]
fn update_security_allows_safe_command() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
assert!(security.is_command_allowed("echo safe"));
}
#[test]
fn add_shell_job_requires_explicit_approval_for_medium_risk() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp);
config.autonomy.allowed_commands = vec!["echo".into(), "touch".into()];
let denied = add_shell_job(
&config,
None,
Schedule::Cron {
expr: "*/5 * * * *".into(),
tz: None,
},
"touch cron-medium-risk",
);
assert!(denied.is_err());
assert!(denied
.unwrap_err()
.to_string()
.contains("explicit approval"));
let approved = add_shell_job_with_approval(
&config,
None,
Schedule::Cron {
expr: "*/5 * * * *".into(),
tz: None,
},
"touch cron-medium-risk",
true,
);
assert!(approved.is_ok(), "{approved:?}");
}
#[test]
fn update_requires_explicit_approval_for_medium_risk() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp);
config.autonomy.allowed_commands = vec!["echo".into(), "touch".into()];
let job = make_job(&config, "*/5 * * * *", None, "echo original");
let denied = update_shell_job_with_approval(
&config,
&job.id,
CronJobPatch {
command: Some("touch cron-medium-risk-update".into()),
..CronJobPatch::default()
},
false,
);
assert!(denied.is_err());
assert!(denied
.unwrap_err()
.to_string()
.contains("explicit approval"));
let approved = update_shell_job_with_approval(
&config,
&job.id,
CronJobPatch {
command: Some("touch cron-medium-risk-update".into()),
..CronJobPatch::default()
},
true,
)
.unwrap();
assert_eq!(approved.command, "touch cron-medium-risk-update");
}
#[test]
fn cli_update_requires_explicit_approval_for_medium_risk() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp);
config.autonomy.allowed_commands = vec!["echo".into(), "touch".into()];
let job = make_job(&config, "*/5 * * * *", None, "echo original");
let result = run_update(
&config,
&job.id,
None,
None,
Some("touch cron-cli-medium-risk"),
None,
);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("explicit approval"));
}
#[test]
fn add_once_validated_creates_one_shot_job() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_once_validated(&config, "1h", "echo one-shot", false).unwrap();
assert_eq!(job.command, "echo one-shot");
assert!(matches!(job.schedule, Schedule::At { .. }));
}
#[test]
fn add_once_validated_blocks_disallowed_command() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp);
config.autonomy.allowed_commands = vec!["echo".into()];
config.autonomy.level = crate::security::AutonomyLevel::Supervised;
let result = add_once_validated(&config, "1h", "curl https://example.com", false);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("blocked by security policy"));
}
#[test]
fn add_once_at_validated_creates_one_shot_job() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let at = chrono::Utc::now() + chrono::Duration::hours(1);
let job = add_once_at_validated(&config, at, "echo at-shot", false).unwrap();
assert_eq!(job.command, "echo at-shot");
assert!(matches!(job.schedule, Schedule::At { .. }));
}
#[test]
fn add_once_at_validated_blocks_medium_risk_without_approval() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp);
config.autonomy.allowed_commands = vec!["echo".into(), "touch".into()];
let at = chrono::Utc::now() + chrono::Duration::hours(1);
let denied = add_once_at_validated(&config, at, "touch at-medium", false);
assert!(denied.is_err());
assert!(denied
.unwrap_err()
.to_string()
.contains("explicit approval"));
let approved = add_once_at_validated(&config, at, "touch at-medium", true);
assert!(approved.is_ok(), "{approved:?}");
}
#[test]
fn gateway_api_path_validates_shell_command() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp);
config.autonomy.allowed_commands = vec!["echo".into()];
config.autonomy.level = crate::security::AutonomyLevel::Supervised;
// Simulate gateway API path: add_shell_job_with_approval(approved=false)
let result = add_shell_job_with_approval(
&config,
None,
Schedule::Cron {
expr: "*/5 * * * *".into(),
tz: None,
},
"curl https://example.com",
false,
);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("blocked by security policy"));
}
#[test]
fn scheduler_path_validates_shell_command() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp);
config.autonomy.allowed_commands = vec!["echo".into()];
config.autonomy.level = crate::security::AutonomyLevel::Supervised;
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
// Simulate scheduler validation path
let result =
validate_shell_command_with_security(&security, "curl https://example.com", false);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("blocked by security policy"));
}
#[test]
fn cli_agent_flag_creates_agent_job() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
handle_command(
crate::CronCommands::Add {
expression: "*/15 * * * *".into(),
tz: None,
agent: true,
allowed_tools: vec![],
command: "Check server health: disk space, memory, CPU load".into(),
},
&config,
)
.unwrap();
let jobs = list_jobs(&config).unwrap();
assert_eq!(jobs.len(), 1);
assert_eq!(jobs[0].job_type, JobType::Agent);
assert_eq!(
jobs[0].prompt.as_deref(),
Some("Check server health: disk space, memory, CPU load")
);
}
#[test]
fn cli_agent_flag_bypasses_shell_security_validation() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp);
config.autonomy.allowed_commands = vec!["echo".into()];
config.autonomy.level = crate::security::AutonomyLevel::Supervised;
// Without --agent, a natural language string would be blocked by shell
// security policy. With --agent, it routes to agent job and skips
// shell validation entirely.
let result = handle_command(
crate::CronCommands::Add {
expression: "*/15 * * * *".into(),
tz: None,
agent: true,
allowed_tools: vec![],
command: "Check server health: disk space, memory, CPU load".into(),
},
&config,
);
assert!(result.is_ok());
let jobs = list_jobs(&config).unwrap();
assert_eq!(jobs.len(), 1);
assert_eq!(jobs[0].job_type, JobType::Agent);
}
#[test]
fn cli_agent_allowed_tools_persist() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
handle_command(
crate::CronCommands::Add {
expression: "*/15 * * * *".into(),
tz: None,
agent: true,
allowed_tools: vec!["file_read".into(), "web_search".into()],
command: "Check server health".into(),
},
&config,
)
.unwrap();
let jobs = list_jobs(&config).unwrap();
assert_eq!(jobs.len(), 1);
assert_eq!(
jobs[0].allowed_tools,
Some(vec!["file_read".into(), "web_search".into()])
);
}
#[test]
fn cli_update_agent_allowed_tools_persist() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_agent_job(
&config,
Some("agent".into()),
Schedule::Cron {
expr: "*/5 * * * *".into(),
tz: None,
},
"original prompt",
SessionTarget::Isolated,
None,
None,
false,
None,
)
.unwrap();
handle_command(
crate::CronCommands::Update {
id: job.id.clone(),
expression: None,
tz: None,
command: None,
name: None,
allowed_tools: vec!["shell".into()],
},
&config,
)
.unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(updated.allowed_tools, Some(vec!["shell".into()]));
}
#[test]
fn cli_without_agent_flag_defaults_to_shell_job() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
handle_command(
crate::CronCommands::Add {
expression: "*/5 * * * *".into(),
tz: None,
agent: false,
allowed_tools: vec![],
command: "echo ok".into(),
},
&config,
)
.unwrap();
let jobs = list_jobs(&config).unwrap();
assert_eq!(jobs.len(), 1);
assert_eq!(jobs[0].job_type, JobType::Shell);
assert_eq!(jobs[0].command, "echo ok");
}
}