Skip to main content

agentos_client/
cron.rs

1//! Cron scheduling + the `CronManager`.
2//!
3//! Ported from `packages/core/src/cron/`. The `schedule` is a 5/6/7-field cron expression (croner
4//! grammar) or an ISO-8601 one-shot timestamp. `CronAction::Callback` is in-process only
5//! (non-serializable). `on_cron_event` returns NO unsubscribe in TS; the Rust equivalent is a
6//! [`tokio::sync::broadcast::Receiver`] whose drop is the unsubscribe.
7//!
8//! Timing is owned by the [`ScheduleDriver`] (mirroring TS `CronManager.schedule` delegating to
9//! `this.driver.schedule({...})`). The default [`crate::config::TimerScheduleDriver`] parses the
10//! schedule, arms the timer, reschedules cron after each fire, and tears down on dispose. The manager
11//! itself only registers job state and runs `execute_job` when the driver fires the callback.
12//!
13//! Cron fields are interpreted in the host LOCAL timezone, matching croner's default behavior.
14
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::sync::Arc;
17
18use chrono::{
19    DateTime, Datelike, Duration as ChronoDuration, Local, SecondsFormat, Timelike, Utc, Weekday,
20};
21use scc::HashMap as SccHashMap;
22use serde::{Deserialize, Serialize};
23use tokio::sync::broadcast;
24
25use crate::agent_os::AgentOs;
26use crate::config::{ScheduleDriver, ScheduleEntry, ScheduleHandle};
27use crate::error::ClientError;
28
29// ---------------------------------------------------------------------------
30// Supporting types
31// ---------------------------------------------------------------------------
32
33/// Overlap policy for a cron job.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum CronOverlap {
37    #[default]
38    Allow,
39    Skip,
40    Queue,
41}
42
43/// A cron action. `Callback` holds an in-process closure and cannot cross the wire.
44#[derive(Clone)]
45pub enum CronAction {
46    /// Run a command via `exec`.
47    Exec { command: String, args: Vec<String> },
48    /// Invoke a host-side callback.
49    Callback {
50        #[allow(clippy::type_complexity)]
51        callback: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
52    },
53}
54
55/// Serializable description of a scheduled action. Callback jobs deliberately
56/// expose only their kind: the host closure is execution state, not job data.
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58#[serde(tag = "type", rename_all = "lowercase")]
59pub enum CronActionInfo {
60    Exec {
61        command: String,
62        #[serde(default, skip_serializing_if = "Vec::is_empty")]
63        args: Vec<String>,
64    },
65    Callback,
66}
67
68impl From<&CronAction> for CronActionInfo {
69    fn from(action: &CronAction) -> Self {
70        match action {
71            CronAction::Exec { command, args } => Self::Exec {
72                command: command.clone(),
73                args: args.clone(),
74            },
75            CronAction::Callback { .. } => Self::Callback,
76        }
77    }
78}
79
80impl std::fmt::Debug for CronAction {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        match self {
83            CronAction::Exec { command, args } => f
84                .debug_struct("Exec")
85                .field("command", command)
86                .field("args", args)
87                .finish(),
88            CronAction::Callback { .. } => f.debug_struct("Callback").finish_non_exhaustive(),
89        }
90    }
91}
92
93/// Options for `schedule_cron`.
94#[derive(Clone)]
95pub struct CronJobOptions {
96    /// Default: a fresh UUID.
97    pub id: Option<String>,
98    /// 5/6/7-field cron expression OR an ISO-8601 one-shot timestamp.
99    pub schedule: String,
100    pub action: CronAction,
101    /// Default: [`CronOverlap::Allow`].
102    pub overlap: Option<CronOverlap>,
103}
104
105/// Snapshot info for a cron job.
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107#[serde(rename_all = "camelCase")]
108pub struct CronJobInfo {
109    pub id: String,
110    pub schedule: String,
111    pub action: CronActionInfo,
112    pub overlap: CronOverlap,
113    pub last_run: Option<String>,
114    pub next_run: Option<String>,
115    pub run_count: u64,
116    pub running: bool,
117}
118
119/// A cron event emitted on each run.
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121#[serde(tag = "type")]
122pub enum CronEvent {
123    #[serde(rename = "cron:fire", rename_all = "camelCase")]
124    Fire { job_id: String, time: String },
125    #[serde(rename = "cron:complete", rename_all = "camelCase")]
126    Complete {
127        job_id: String,
128        time: String,
129        duration_ms: f64,
130    },
131    #[serde(rename = "cron:error", rename_all = "camelCase")]
132    Error {
133        job_id: String,
134        time: String,
135        error: String,
136    },
137}
138
139/// Handle to a scheduled cron job. Dropping or calling [`CronJobHandle::cancel`] cancels it.
140#[derive(Clone)]
141pub struct CronJobHandle {
142    pub id: String,
143    pub(crate) manager: Arc<CronManager>,
144}
145
146impl CronJobHandle {
147    /// Cancel the job (no-op if already cancelled/unknown).
148    pub fn cancel(&self) {
149        self.manager.cancel_job(&self.id);
150    }
151}
152
153// ---------------------------------------------------------------------------
154// CronManager + CronJobState
155// ---------------------------------------------------------------------------
156
157/// Internal per-job state.
158pub(crate) struct CronJobState {
159    pub schedule: String,
160    pub action: CronAction,
161    pub overlap: CronOverlap,
162    pub last_run: parking_lot::Mutex<Option<DateTime<Utc>>>,
163    pub next_run: parking_lot::Mutex<Option<DateTime<Utc>>>,
164    pub run_count: std::sync::atomic::AtomicU64,
165    pub running: AtomicBool,
166    /// Set when a `Queue`-policy fire arrives while the job is already running; drained to exactly
167    /// one deferred run when the active run completes. Mirrors TS `CronJobState.queued`.
168    pub queued: AtomicBool,
169    /// Driver-returned timer handle. Used by `cancel`/`dispose` to tear down the armed timer through
170    /// the driver, mirroring TS `this.driver.cancel(state.handle)`.
171    pub handle: ScheduleHandle,
172}
173
174/// Owns scheduled jobs, the schedule driver, and the cron event broadcast.
175pub struct CronManager {
176    pub(crate) jobs: SccHashMap<String, CronJobState>,
177    pub(crate) schedule_lock: parking_lot::Mutex<()>,
178    pub(crate) driver: Arc<dyn ScheduleDriver>,
179    pub(crate) event_tx: broadcast::Sender<CronEvent>,
180}
181
182impl CronManager {
183    /// Create a cron manager with the given schedule driver.
184    pub(crate) fn new(driver: Arc<dyn ScheduleDriver>) -> Self {
185        let (event_tx, _rx) = broadcast::channel(256);
186        Self {
187            jobs: SccHashMap::new(),
188            schedule_lock: parking_lot::Mutex::new(()),
189            driver,
190            event_tx,
191        }
192    }
193
194    /// Cancel a job by id (no-op if unknown).
195    ///
196    /// Mirrors TS `CronManager.cancel`: cancel the driver-armed timer (`this.driver.cancel(handle)`)
197    /// and remove the job from the registry.
198    pub(crate) fn cancel_job(&self, id: &str) {
199        let _guard = self.schedule_lock.lock();
200        if let Some((_, state)) = self.jobs.remove(id) {
201            self.driver.cancel(&state.handle);
202        }
203    }
204
205    /// Dispose all jobs (called during shutdown).
206    ///
207    /// Mirrors TS `CronManager.dispose`: cancel every armed timer through the driver, clear the
208    /// registry, then call `this.driver.dispose()` to tear down all driver-held timer state.
209    pub(crate) fn dispose(&self) {
210        let _guard = self.schedule_lock.lock();
211        self.jobs.scan(|_, state| {
212            self.driver.cancel(&state.handle);
213        });
214        self.jobs.clear();
215        self.driver.dispose();
216    }
217}
218
219/// Execute a single job run, honoring the overlap policy. Emits `Fire`, then `Complete` or `Error`.
220/// Re-runs once at the end if a `Queue`-policy run was deferred while busy.
221///
222/// Mirrors TS `CronManager.executeJob`. Handler/action errors never crash the manager; on error a
223/// `cron:error` event is emitted instead of a `cron:complete`. Returns an explicitly boxed `Send`
224/// future (rather than an `async fn`) so the recursive queued re-run does not form a
225/// self-referential async auto-trait inference cycle that would defeat the `Send` bound required by
226/// [`tokio::spawn`].
227fn execute_job(
228    manager: Arc<CronManager>,
229    vm: AgentOs,
230    id: String,
231) -> futures::future::BoxFuture<'static, ()> {
232    Box::pin(execute_job_inner(manager, vm, id))
233}
234
235async fn execute_job_inner(manager: Arc<CronManager>, vm: AgentOs, id: String) {
236    let manager = &manager;
237    let vm = &vm;
238    let id = id.as_str();
239    // Overlap policy: a running job either allows a concurrent run, skips this fire, or queues
240    // exactly one deferred run.
241    {
242        let mut should_return = false;
243        let mut should_queue = false;
244        manager.jobs.read(id, |_, state| {
245            if state.running.load(Ordering::SeqCst) {
246                match state.overlap {
247                    CronOverlap::Allow => {}
248                    CronOverlap::Skip => should_return = true,
249                    CronOverlap::Queue => should_queue = true,
250                }
251            }
252        });
253        if should_return {
254            return;
255        }
256        if should_queue {
257            manager.jobs.read(id, |_, state| {
258                state.queued.store(true, Ordering::SeqCst);
259            });
260            return;
261        }
262    }
263
264    // Mark running, record this run, and snapshot the action to dispatch.
265    let action = match manager.jobs.read(id, |_, state| {
266        state.running.store(true, Ordering::SeqCst);
267        *state.last_run.lock() = Some(Utc::now());
268        state.run_count.fetch_add(1, Ordering::SeqCst);
269        state.action.clone()
270    }) {
271        Some(action) => action,
272        None => return,
273    };
274
275    let _ = manager.event_tx.send(CronEvent::Fire {
276        job_id: id.to_string(),
277        time: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
278    });
279
280    // TS `durationMs = Date.now() - startTime`, an integer millisecond count.
281    let start = Utc::now();
282    let result = run_action(vm, &action).await;
283    let duration_ms = (Utc::now() - start).num_milliseconds() as f64;
284
285    match result {
286        Ok(()) => {
287            let _ = manager.event_tx.send(CronEvent::Complete {
288                job_id: id.to_string(),
289                time: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
290                duration_ms,
291            });
292        }
293        Err(error) => {
294            let _ = manager.event_tx.send(CronEvent::Error {
295                job_id: id.to_string(),
296                time: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
297                error: error.to_string(),
298            });
299        }
300    }
301
302    // Clear running, recompute the next run, and drain a queued run if one was deferred.
303    let mut run_queued = false;
304    manager.jobs.read(id, |_, state| {
305        state.running.store(false, Ordering::SeqCst);
306        *state.next_run.lock() = compute_next_time(&state.schedule, Utc::now());
307        if state.queued.swap(false, Ordering::SeqCst) {
308            run_queued = true;
309        }
310    });
311
312    if run_queued {
313        let manager = Arc::clone(manager);
314        let vm = vm.clone();
315        let id = id.to_string();
316        tokio::spawn(execute_job(manager, vm, id));
317    }
318}
319
320/// Dispatch a [`CronAction`]. Mirrors TS `CronManager.runAction`.
321///
322/// `Exec` sends the structured `(command, args)` argv
323/// verbatim via [`AgentOs::exec_argv`] (no string flattening / re-parsing). `Callback` awaits the
324/// in-process future.
325async fn run_action(vm: &AgentOs, action: &CronAction) -> Result<(), ClientError> {
326    match action {
327        CronAction::Exec { command, args } => {
328            // Send the structured argv verbatim. Flattening `command`/`args` into a single string
329            // and re-parsing it through the `exec` command-line parser would re-split argv elements
330            // on whitespace and shell-evaluate any `$()`/backtick content; `exec_argv` preserves the
331            // structured (command, args) contract element-for-element.
332            vm.exec_argv_process(command, args, crate::process::ExecOptions::default())
333                .await
334                .map_err(|err| ClientError::Sidecar(err.to_string()))?;
335            Ok(())
336        }
337        CronAction::Callback { callback } => {
338            callback().await;
339            Ok(())
340        }
341    }
342}
343
344// ---------------------------------------------------------------------------
345// Schedule validation
346// ---------------------------------------------------------------------------
347
348/// A parsed schedule: either a recurring cron expression or a one-shot ISO-8601 timestamp.
349///
350/// Mirrors TS `ParsedSchedule` (`parse-schedule.ts`).
351pub(crate) enum ParsedSchedule {
352    /// A one-shot absolute timestamp.
353    Date(DateTime<Utc>),
354    /// A recurring cron expression (croner grammar).
355    Cron(CronExpr),
356}
357
358impl ParsedSchedule {
359    /// `true` for a recurring cron expression. Mirrors TS `parsed.kind === "cron"`.
360    pub(crate) fn is_cron(&self) -> bool {
361        matches!(self, ParsedSchedule::Cron(_))
362    }
363}
364
365/// Resolve the next run for an already-parsed schedule strictly after `now`. Mirrors TS
366/// `resolveSchedule(...).nextRun`: a cron yields `cron.nextRun()`; a one-shot date yields the date if
367/// it is in the future, else `None`.
368pub(crate) fn resolve_next_run(
369    parsed: &ParsedSchedule,
370    now: DateTime<Utc>,
371) -> Option<DateTime<Utc>> {
372    match parsed {
373        ParsedSchedule::Cron(cron) => cron.next_after(now),
374        ParsedSchedule::Date(date) => {
375            if date.timestamp_millis() > now.timestamp_millis() {
376                Some(*date)
377            } else {
378                None
379            }
380        }
381    }
382}
383
384/// Decide whether a schedule string looks like a one-shot ISO-8601-ish timestamp rather than a cron
385/// expression. Mirrors TS `looksLikeOneShotSchedule` /
386/// `^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})?)?$`, with the
387/// fractional-seconds group widened to accept any number of digits so a Rust-produced RFC-3339
388/// timestamp (up to 9 fractional digits) is recognized as a one-shot.
389fn looks_like_one_shot(schedule: &str) -> bool {
390    let bytes = schedule.as_bytes();
391    let mut i = 0usize;
392
393    let is_digit = |b: u8| b.is_ascii_digit();
394
395    let take_digits = |bytes: &[u8], i: &mut usize, n: usize| -> bool {
396        for _ in 0..n {
397            match bytes.get(*i) {
398                Some(&b) if is_digit(b) => *i += 1,
399                _ => return false,
400            }
401        }
402        true
403    };
404    let take_lit = |bytes: &[u8], i: &mut usize, lit: u8| -> bool {
405        match bytes.get(*i) {
406            Some(&b) if b == lit => {
407                *i += 1;
408                true
409            }
410            _ => false,
411        }
412    };
413
414    if !take_digits(bytes, &mut i, 4) {
415        return false;
416    }
417    if !take_lit(bytes, &mut i, b'-') {
418        return false;
419    }
420    if !take_digits(bytes, &mut i, 2) {
421        return false;
422    }
423    if !take_lit(bytes, &mut i, b'-') {
424        return false;
425    }
426    if !take_digits(bytes, &mut i, 2) {
427        return false;
428    }
429
430    // Optional time portion: [T ]HH:MM(:SS(.fff)?)?(Z|[+-]HH:MM)?
431    if i == bytes.len() {
432        return true;
433    }
434    match bytes.get(i) {
435        Some(b'T') | Some(b' ') => i += 1,
436        _ => return false,
437    }
438    if !take_digits(bytes, &mut i, 2) {
439        return false;
440    }
441    if !take_lit(bytes, &mut i, b':') {
442        return false;
443    }
444    if !take_digits(bytes, &mut i, 2) {
445        return false;
446    }
447
448    // Optional :SS
449    if take_lit(bytes, &mut i, b':') {
450        if !take_digits(bytes, &mut i, 2) {
451            return false;
452        }
453        // Optional fractional seconds. The TS regex caps this at `\.\d{1,3}`, but a Rust-produced
454        // one-shot from `chrono::DateTime::to_rfc3339()` emits up to 9 fractional digits, so a valid
455        // near-future RFC-3339 timestamp must not be misclassified as a cron expression. Accept any
456        // run of one or more fractional digits.
457        if take_lit(bytes, &mut i, b'.') {
458            let mut frac = 0;
459            while matches!(bytes.get(i), Some(&b) if is_digit(b)) {
460                i += 1;
461                frac += 1;
462            }
463            if frac == 0 {
464                return false;
465            }
466        }
467    }
468
469    // Optional timezone: Z | [+-]HH:MM
470    match bytes.get(i) {
471        None => return true,
472        Some(b'Z') => {
473            i += 1;
474        }
475        Some(b'+') | Some(b'-') => {
476            i += 1;
477            if !take_digits(bytes, &mut i, 2) {
478                return false;
479            }
480            if !take_lit(bytes, &mut i, b':') {
481                return false;
482            }
483            if !take_digits(bytes, &mut i, 2) {
484                return false;
485            }
486        }
487        _ => return false,
488    }
489
490    i == bytes.len()
491}
492
493/// Parse a one-shot timestamp string into a UTC instant, matching ECMAScript `Date.parse` rules for
494/// the subset accepted by [`looks_like_one_shot`]:
495/// - a date-only string (`2026-06-04`) is UTC midnight;
496/// - a date-time string WITHOUT an offset (`2026-06-04T12:30`, `2026-06-04 12:30`) is parsed as LOCAL
497///   time;
498/// - forms with `Z` or an explicit numeric offset are parsed as written.
499fn parse_one_shot(schedule: &str) -> Option<DateTime<Utc>> {
500    use chrono::TimeZone;
501
502    // Try a full RFC-3339 timestamp first (handles Z and numeric offsets).
503    if let Ok(dt) = DateTime::parse_from_rfc3339(schedule) {
504        return Some(dt.with_timezone(&Utc));
505    }
506
507    // Normalize a space separator to `T` for the naive parsers below.
508    let normalized = schedule.replacen(' ', "T", 1);
509
510    // Date + time without a timezone: ECMAScript treats this as LOCAL time.
511    for fmt in [
512        "%Y-%m-%dT%H:%M:%S%.f",
513        "%Y-%m-%dT%H:%M:%S",
514        "%Y-%m-%dT%H:%M",
515    ] {
516        if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(&normalized, fmt) {
517            return match Local.from_local_datetime(&naive) {
518                chrono::LocalResult::Single(dt) => Some(dt.with_timezone(&Utc)),
519                chrono::LocalResult::Ambiguous(dt, _) => Some(dt.with_timezone(&Utc)),
520                chrono::LocalResult::None => None,
521            };
522        }
523    }
524
525    // Date only: midnight UTC (ECMAScript date-only form is UTC).
526    if let Ok(date) = chrono::NaiveDate::parse_from_str(schedule, "%Y-%m-%d") {
527        let naive = date.and_hms_opt(0, 0, 0)?;
528        return Some(DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc));
529    }
530
531    None
532}
533
534/// Parse a schedule string into a [`ParsedSchedule`]. Mirrors TS `parseSchedule`.
535pub(crate) fn parse_schedule(schedule: &str) -> std::result::Result<ParsedSchedule, ClientError> {
536    let normalized = schedule.trim();
537    if looks_like_one_shot(normalized) {
538        return match parse_one_shot(normalized) {
539            Some(date) => Ok(ParsedSchedule::Date(date)),
540            None => Err(ClientError::InvalidSchedule(schedule.to_string())),
541        };
542    }
543
544    match CronExpr::parse(normalized) {
545        Ok(cron) => Ok(ParsedSchedule::Cron(cron)),
546        Err(_) => Err(ClientError::InvalidSchedule(schedule.to_string())),
547    }
548}
549
550/// Compute the next fire time for a schedule string strictly after `now`. Returns `None` for a
551/// one-shot timestamp in the past or a cron expression with no upcoming match. Mirrors TS
552/// `computeNextTime` / `resolveSchedule(...).nextRun`.
553pub(crate) fn compute_next_time(schedule: &str, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
554    let parsed = parse_schedule(schedule).ok()?;
555    resolve_next_run(&parsed, now)
556}
557
558/// Validate a schedule string. Returns the parsed next run for one-shot ISO-8601 schedules.
559///
560/// Errors `InvalidSchedule` for malformed input and `PastSchedule` for one-shot timestamps already
561/// in the past. Mirrors TS `validateScheduleForRegistration`: a one-shot timestamp that resolves to
562/// no next run is rejected as `PastSchedule`; cron expressions are accepted even when their next run
563/// is currently unknown.
564pub(crate) fn validate_schedule(
565    schedule: &str,
566    now: DateTime<Utc>,
567) -> std::result::Result<Option<DateTime<Utc>>, ClientError> {
568    let parsed = parse_schedule(schedule)?;
569    match parsed {
570        ParsedSchedule::Cron(cron) => Ok(cron.next_after(now)),
571        ParsedSchedule::Date(date) => {
572            if date.timestamp_millis() > now.timestamp_millis() {
573                Ok(Some(date))
574            } else {
575                Err(ClientError::PastSchedule(schedule.to_string()))
576            }
577        }
578    }
579}
580
581// ---------------------------------------------------------------------------
582// Cron expression parser + next-run search (croner-compatible grammar)
583// ---------------------------------------------------------------------------
584
585/// A parsed cron expression interpreted in the host LOCAL timezone (matching croner's default).
586///
587/// Implemented in-crate because the workspace has no cron-parsing dependency. Accepts the croner
588/// grammar: 5-field (`min hour dom month dow`), 6-field (leading `seconds`), and 7-field (leading
589/// `seconds`, trailing `year`) expressions; named months (`JAN`-`DEC`); named weekdays (`SUN`-`SAT`);
590/// `*`, ranges (`a-b`), steps (`*/n`, `a-b/n`, `a/n`), comma lists, `?` (treated as `*` for
591/// dom/dow), `L` (last day of month for dom, last weekday-of-month for dow), `#` (nth weekday), and
592/// `W` (nearest weekday to a day-of-month). Day-of-month and day-of-week combine with OR semantics
593/// when both are restricted, matching Vixie/croner.
594pub(crate) struct CronExpr {
595    seconds: Vec<u32>,
596    minutes: Vec<u32>,
597    hours: Vec<u32>,
598    days_of_month: Vec<u32>,
599    months: Vec<u32>,
600    days_of_week: Vec<u32>,
601    years: Option<Vec<u32>>,
602    dom_restricted: bool,
603    dow_restricted: bool,
604    /// Day-of-month `L` (last day of month).
605    dom_last: bool,
606    /// Day-of-month `LW` (last weekday, Mon-Fri, on or before the last day of the month).
607    dom_last_weekday: bool,
608    /// Day-of-month `<n>W` (nearest weekday to day `n`).
609    dom_nearest_weekday: Option<u32>,
610    /// Day-of-week `<weekday>L` (last given weekday of the month).
611    dow_last: Option<u32>,
612    /// Day-of-week `<weekday>#<n>` (nth given weekday of the month).
613    dow_nth: Option<(u32, u32)>,
614}
615
616const MONTH_NAMES: [&str; 12] = [
617    "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC",
618];
619const WEEKDAY_NAMES: [&str; 7] = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
620
621impl CronExpr {
622    fn parse(expr: &str) -> std::result::Result<Self, ()> {
623        let fields: Vec<&str> = expr.split_whitespace().collect();
624
625        // Accept 5, 6, or 7 fields. 6-field adds a leading seconds field; 7-field adds a trailing
626        // year field on top of that. Mirrors croner's field-count handling.
627        let (sec, min, hour, dom, month, dow, year): (
628            &str,
629            &str,
630            &str,
631            &str,
632            &str,
633            &str,
634            Option<&str>,
635        ) = match fields.len() {
636            5 => (
637                "0", fields[0], fields[1], fields[2], fields[3], fields[4], None,
638            ),
639            6 => (
640                fields[0], fields[1], fields[2], fields[3], fields[4], fields[5], None,
641            ),
642            7 => (
643                fields[0],
644                fields[1],
645                fields[2],
646                fields[3],
647                fields[4],
648                fields[5],
649                Some(fields[6]),
650            ),
651            _ => return Err(()),
652        };
653
654        let seconds = parse_field(sec, 0, 59, FieldKind::Plain)?;
655        let minutes = parse_field(min, 0, 59, FieldKind::Plain)?;
656        let hours = parse_field(hour, 0, 23, FieldKind::Plain)?;
657
658        let mut dom_last = false;
659        let mut dom_last_weekday = false;
660        let mut dom_nearest_weekday = None;
661        let days_of_month = parse_dom_field(
662            dom,
663            &mut dom_last,
664            &mut dom_last_weekday,
665            &mut dom_nearest_weekday,
666        )?;
667
668        let months = parse_field(month, 1, 12, FieldKind::Month)?;
669
670        let mut dow_last = None;
671        let mut dow_nth = None;
672        let days_of_week = parse_dow_field(dow, &mut dow_last, &mut dow_nth)?;
673
674        let years = match year {
675            Some(y) => Some(parse_field(y, 1970, 2099, FieldKind::Plain)?),
676            None => None,
677        };
678
679        // `?` is equivalent to `*` for matching purposes, so the field is "unrestricted".
680        let dom_restricted = dom != "*" && dom != "?";
681        let dow_restricted = dow != "*" && dow != "?";
682
683        Ok(Self {
684            seconds,
685            minutes,
686            hours,
687            days_of_month,
688            months,
689            days_of_week,
690            years,
691            dom_restricted,
692            dow_restricted,
693            dom_last,
694            dom_last_weekday,
695            dom_nearest_weekday,
696            dow_last,
697            dow_nth,
698        })
699    }
700
701    /// Find the next instant strictly after `after` (truncated to whole seconds) that matches, in the
702    /// LOCAL timezone. Scans second-by-second only when a sub-minute (seconds) constraint is present;
703    /// otherwise scans minute-by-minute. Bounded so an impossible expression terminates.
704    fn next_after(&self, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
705        let local_after = after.with_timezone(&Local);
706
707        // Determine the step granularity. When seconds is the default `[0]` we can step by minutes.
708        let by_seconds = self.seconds != vec![0];
709
710        let step = if by_seconds {
711            ChronoDuration::seconds(1)
712        } else {
713            ChronoDuration::minutes(1)
714        };
715
716        let mut candidate = if by_seconds {
717            local_after.with_nanosecond(0)? + ChronoDuration::seconds(1)
718        } else {
719            local_after.with_second(0)?.with_nanosecond(0)? + ChronoDuration::minutes(1)
720        };
721
722        // Bound the search: a few years of ticks so an impossible expression terminates.
723        let max_iterations: u64 = if by_seconds {
724            // ~2 years of seconds.
725            2u64 * 366 * 24 * 60 * 60
726        } else {
727            // ~6 years of minutes (years field can push matches far out).
728            6u64 * 366 * 24 * 60
729        };
730        for _ in 0..max_iterations {
731            if self.matches_local(&candidate) {
732                return Some(candidate.with_timezone(&Utc));
733            }
734            candidate += step;
735        }
736        None
737    }
738
739    fn matches_local(&self, dt: &DateTime<Local>) -> bool {
740        if !self.seconds.contains(&dt.second()) {
741            return false;
742        }
743        if !self.minutes.contains(&dt.minute()) {
744            return false;
745        }
746        if !self.hours.contains(&dt.hour()) {
747            return false;
748        }
749        if !self.months.contains(&dt.month()) {
750            return false;
751        }
752        if let Some(years) = &self.years {
753            let year = dt.year();
754            if year < 0 || !years.contains(&(year as u32)) {
755                return false;
756            }
757        }
758
759        let dom_match = self.dom_matches(dt);
760        let dow_match = self.dow_matches(dt);
761
762        // Vixie/croner OR semantics: if both DOM and DOW are restricted, a match in either suffices;
763        // if only one is restricted, only that one is consulted; if neither, both pass.
764        match (self.dom_restricted, self.dow_restricted) {
765            (true, true) => dom_match || dow_match,
766            (true, false) => dom_match,
767            (false, true) => dow_match,
768            (false, false) => true,
769        }
770    }
771
772    fn dom_matches(&self, dt: &DateTime<Local>) -> bool {
773        let dom = dt.day();
774        if self.dom_last && dom == last_day_of_month(dt.year(), dt.month()) {
775            return true;
776        }
777        if self.dom_last_weekday {
778            // Last weekday (Mon-Fri) on or before the last day of the month: the nearest-weekday
779            // resolution of the last day handles the Saturday/Sunday shift back into the month.
780            if is_nearest_weekday(dt, last_day_of_month(dt.year(), dt.month())) {
781                return true;
782            }
783        }
784        if let Some(target) = self.dom_nearest_weekday {
785            if is_nearest_weekday(dt, target) {
786                return true;
787            }
788        }
789        self.days_of_month.contains(&dom)
790    }
791
792    fn dow_matches(&self, dt: &DateTime<Local>) -> bool {
793        let dow = weekday_sun0(dt.weekday());
794
795        if let Some(target) = self.dow_last {
796            // Last occurrence of `target` weekday in this month.
797            if dow == target {
798                let next_week = *dt + ChronoDuration::days(7);
799                if next_week.month() != dt.month() {
800                    return true;
801                }
802            }
803        }
804        if let Some((target, n)) = self.dow_nth {
805            if dow == target {
806                // 1-based occurrence index of this weekday within the month.
807                let occurrence = (dt.day() - 1) / 7 + 1;
808                if occurrence == n {
809                    return true;
810                }
811            }
812        }
813        self.days_of_week.contains(&dow)
814    }
815}
816
817/// Convert chrono `Weekday` to cron's `Sun=0..Sat=6` numbering.
818fn weekday_sun0(weekday: Weekday) -> u32 {
819    weekday.num_days_from_sunday()
820}
821
822/// Last calendar day of a given month.
823fn last_day_of_month(year: i32, month: u32) -> u32 {
824    let (ny, nm) = if month == 12 {
825        (year + 1, 1)
826    } else {
827        (year, month + 1)
828    };
829    let first_next = chrono::NaiveDate::from_ymd_opt(ny, nm, 1).expect("valid first-of-month");
830    (first_next - ChronoDuration::days(1)).day()
831}
832
833/// Whether `dt` is the nearest weekday (Mon-Fri) to day-of-month `target` within the same month,
834/// per cron `W` semantics. If `target` falls on a weekend, the nearest weekday in the same month is
835/// used (Saturday shifts to Friday, Sunday shifts to Monday); a shift never crosses the month
836/// boundary.
837fn is_nearest_weekday(dt: &DateTime<Local>, target: u32) -> bool {
838    let last = last_day_of_month(dt.year(), dt.month());
839    let target = target.min(last);
840    let target_date = chrono::NaiveDate::from_ymd_opt(dt.year(), dt.month(), target);
841    let target_date = match target_date {
842        Some(d) => d,
843        None => return false,
844    };
845    let target_weekday = target_date.weekday();
846    let resolved_day = match target_weekday {
847        Weekday::Sat => {
848            if target > 1 {
849                target - 1
850            } else {
851                // Saturday on the 1st shifts forward to Monday (the 3rd).
852                target + 2
853            }
854        }
855        Weekday::Sun => {
856            if target < last {
857                target + 1
858            } else {
859                // Sunday on the last day shifts back to Friday.
860                target - 2
861            }
862        }
863        Weekday::Mon | Weekday::Tue | Weekday::Wed | Weekday::Thu | Weekday::Fri => target,
864    };
865    dt.day() == resolved_day
866}
867
868#[derive(Clone, Copy, PartialEq, Eq)]
869enum FieldKind {
870    Plain,
871    Month,
872    Weekday,
873}
874
875/// Parse a numeric/named cron field (`*`, `?`, lists, ranges, steps) into the sorted set of matching
876/// values within `[min, max]`. `?` is treated as `*`. For [`FieldKind::Month`] names `JAN`-`DEC` are
877/// accepted.
878fn parse_field(
879    field: &str,
880    min: u32,
881    max: u32,
882    kind: FieldKind,
883) -> std::result::Result<Vec<u32>, ()> {
884    if field == "?" {
885        // `?` = no specific value; treat as the full range.
886        return Ok((min..=max).collect());
887    }
888    let mut values: Vec<u32> = Vec::new();
889    for part in field.split(',') {
890        if part.is_empty() {
891            return Err(());
892        }
893        parse_field_part(part, min, max, kind, &mut values)?;
894    }
895    if values.is_empty() {
896        return Err(());
897    }
898    values.sort_unstable();
899    values.dedup();
900    Ok(values)
901}
902
903/// Parse the day-of-month field, recognizing `L` (last day), `LW` (last weekday), and `<n>W` (nearest
904/// weekday to day `n`) in addition to the standard grammar. Mirrors croner: `W` must be preceded by
905/// `L` or a single day-of-month value in `1..=31` (`W` alone, `0W`, and `32W` are rejected).
906fn parse_dom_field(
907    field: &str,
908    dom_last: &mut bool,
909    dom_last_weekday: &mut bool,
910    dom_nearest_weekday: &mut Option<u32>,
911) -> std::result::Result<Vec<u32>, ()> {
912    let upper = field.to_ascii_uppercase();
913    if upper == "L" {
914        *dom_last = true;
915        // No fixed numeric days; matching handled by `dom_last`.
916        return Ok(Vec::new());
917    }
918    if upper == "LW" {
919        *dom_last_weekday = true;
920        return Ok(Vec::new());
921    }
922    if let Some(stripped) = upper.strip_suffix('W') {
923        let day: u32 = stripped.parse().map_err(|_| ())?;
924        if !(1..=31).contains(&day) {
925            return Err(());
926        }
927        *dom_nearest_weekday = Some(day);
928        return Ok(Vec::new());
929    }
930    parse_field(field, 1, 31, FieldKind::Plain)
931}
932
933/// Parse the day-of-week field, recognizing `<weekday>L` (last weekday-of-month) and
934/// `<weekday>#<n>` (nth weekday-of-month), named weekdays, and `7` folded onto Sunday.
935fn parse_dow_field(
936    field: &str,
937    dow_last: &mut Option<u32>,
938    dow_nth: &mut Option<(u32, u32)>,
939) -> std::result::Result<Vec<u32>, ()> {
940    let upper = field.to_ascii_uppercase();
941
942    // `<weekday>#<n>` (nth weekday of the month).
943    if let Some((wd, nth)) = upper.split_once('#') {
944        let weekday = parse_weekday_token(wd)?;
945        let n: u32 = nth.parse().map_err(|_| ())?;
946        if !(1..=5).contains(&n) {
947            return Err(());
948        }
949        *dow_nth = Some((weekday, n));
950        return Ok(Vec::new());
951    }
952
953    // `<weekday>L` (last given weekday of the month).
954    if let Some(stripped) = upper.strip_suffix('L') {
955        let weekday = parse_weekday_token(stripped)?;
956        *dow_last = Some(weekday);
957        return Ok(Vec::new());
958    }
959
960    if upper == "?" || upper == "*" {
961        let mut v = parse_field(field, 0, 7, FieldKind::Plain)?;
962        fold_sunday(&mut v);
963        return Ok(v);
964    }
965
966    let mut values = parse_field(field, 0, 7, FieldKind::Weekday)?;
967    fold_sunday(&mut values);
968    Ok(values)
969}
970
971/// Fold `7` (Sunday) onto `0` and dedupe.
972fn fold_sunday(values: &mut Vec<u32>) {
973    for v in values.iter_mut() {
974        if *v == 7 {
975            *v = 0;
976        }
977    }
978    values.sort_unstable();
979    values.dedup();
980}
981
982/// Parse a single weekday token (numeric `0`-`7` or named `SUN`-`SAT`) to `Sun=0..Sat=6`.
983fn parse_weekday_token(token: &str) -> std::result::Result<u32, ()> {
984    let upper = token.to_ascii_uppercase();
985    if let Some(idx) = WEEKDAY_NAMES.iter().position(|name| *name == upper) {
986        return Ok(idx as u32);
987    }
988    let v: u32 = upper.parse().map_err(|_| ())?;
989    match v {
990        0..=6 => Ok(v),
991        7 => Ok(0),
992        _ => Err(()),
993    }
994}
995
996// Re-add FieldKind::Weekday support by extending parse_field via a wrapper for weekday names.
997impl FieldKind {
998    fn resolve_name(self, token: &str) -> Option<u32> {
999        let upper = token.to_ascii_uppercase();
1000        match self {
1001            FieldKind::Plain => None,
1002            FieldKind::Month => MONTH_NAMES
1003                .iter()
1004                .position(|name| *name == upper)
1005                .map(|i| (i + 1) as u32),
1006            FieldKind::Weekday => WEEKDAY_NAMES
1007                .iter()
1008                .position(|name| *name == upper)
1009                .map(|i| i as u32),
1010        }
1011    }
1012}
1013
1014fn parse_field_part(
1015    part: &str,
1016    min: u32,
1017    max: u32,
1018    kind: FieldKind,
1019    out: &mut Vec<u32>,
1020) -> std::result::Result<(), ()> {
1021    // Split off an optional step (`.../n`).
1022    let (range_spec, step) = match part.split_once('/') {
1023        Some((range_spec, step_str)) => {
1024            let step: u32 = step_str.parse().map_err(|_| ())?;
1025            if step == 0 {
1026                return Err(());
1027            }
1028            (range_spec, Some(step))
1029        }
1030        None => (part, None),
1031    };
1032
1033    // Determine the [start, end] bounds for this part.
1034    let (start, end) = if range_spec == "*" {
1035        (min, max)
1036    } else if let Some((lo, hi)) = range_spec.split_once('-') {
1037        let lo = parse_value_token(lo, kind)?;
1038        let hi = parse_value_token(hi, kind)?;
1039        (lo, hi)
1040    } else {
1041        // A bare numeric value may not carry a step. croner rejects `5/15` / `0/5`
1042        // ("stepping with numeric prefix"); only `*` or an explicit range may precede `/`.
1043        if step.is_some() {
1044            return Err(());
1045        }
1046        let v = parse_value_token(range_spec, kind)?;
1047        (v, v)
1048    };
1049
1050    if start < min || end > max || start > end {
1051        return Err(());
1052    }
1053
1054    let step = step.unwrap_or(1);
1055    let mut v = start;
1056    while v <= end {
1057        out.push(v);
1058        v += step;
1059    }
1060    Ok(())
1061}
1062
1063/// Parse a single value token: a number, or a name (month names for [`FieldKind::Month`], weekday
1064/// names for [`FieldKind::Weekday`]).
1065fn parse_value_token(token: &str, kind: FieldKind) -> std::result::Result<u32, ()> {
1066    match kind {
1067        FieldKind::Weekday => parse_weekday_token(token),
1068        FieldKind::Month => {
1069            if let Some(v) = kind.resolve_name(token) {
1070                return Ok(v);
1071            }
1072            token.parse().map_err(|_| ())
1073        }
1074        FieldKind::Plain => token.parse().map_err(|_| ()),
1075    }
1076}
1077
1078// ---------------------------------------------------------------------------
1079// Methods
1080// ---------------------------------------------------------------------------
1081
1082impl AgentOs {
1083    /// Schedule a cron job. SYNC. Validates the schedule (errors `InvalidSchedule` / `PastSchedule`).
1084    /// `id` defaults to a UUID; `overlap` defaults to allow.
1085    ///
1086    /// Mirrors TS `AgentOs.scheduleCron` / `CronManager.schedule`: validation happens up front, the
1087    /// driver is asked to arm the timer (`this.driver.schedule({ id, schedule, callback })`), and the
1088    /// job is registered. The driver owns all timing: it parses the schedule, fires the callback,
1089    /// reschedules cron after each fire, and is cancelled on [`CronJobHandle::cancel`] /
1090    /// [`CronManager::dispose`]. The returned [`CronJobHandle`] cancels the job.
1091    pub fn schedule_cron(
1092        &self,
1093        options: CronJobOptions,
1094    ) -> std::result::Result<CronJobHandle, ClientError> {
1095        let cron = self.cron();
1096        let now = Utc::now();
1097
1098        // Validate before any state mutation, matching TS `validateScheduleForRegistration`.
1099        let next_run = validate_schedule(&options.schedule, now)?;
1100
1101        let id = options
1102            .id
1103            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1104        let overlap = options.overlap.unwrap_or_default();
1105
1106        // Build the driver callback that runs one job execution, mirroring TS
1107        // `callback: () => this.executeJob(id)`.
1108        let manager = Arc::clone(cron);
1109        let vm = self.clone();
1110        let callback_id = id.clone();
1111        let callback: crate::config::ScheduleCallback = Arc::new(move || {
1112            let manager = Arc::clone(&manager);
1113            let vm = vm.clone();
1114            let id = callback_id.clone();
1115            Box::pin(async move {
1116                execute_job(manager, vm, id).await;
1117            })
1118        });
1119
1120        register_cron_job(
1121            cron,
1122            id,
1123            options.schedule,
1124            options.action,
1125            overlap,
1126            next_run,
1127            callback,
1128        )
1129    }
1130
1131    /// Snapshot all cron jobs. Mirrors TS `CronManager.list`.
1132    pub fn list_cron_jobs(&self) -> Vec<CronJobInfo> {
1133        let mut result = Vec::new();
1134        self.cron().jobs.scan(|id, state| {
1135            result.push(CronJobInfo {
1136                id: id.clone(),
1137                schedule: state.schedule.clone(),
1138                action: CronActionInfo::from(&state.action),
1139                overlap: state.overlap,
1140                last_run: state
1141                    .last_run
1142                    .lock()
1143                    .as_ref()
1144                    .map(|time| time.to_rfc3339_opts(SecondsFormat::Millis, true)),
1145                next_run: state
1146                    .next_run
1147                    .lock()
1148                    .as_ref()
1149                    .map(|time| time.to_rfc3339_opts(SecondsFormat::Millis, true)),
1150                run_count: state.run_count.load(Ordering::SeqCst),
1151                running: state.running.load(Ordering::SeqCst),
1152            });
1153        });
1154        result
1155    }
1156
1157    /// Cancel a cron job. No-op if unknown; never errors. Mirrors TS `CronManager.cancel`.
1158    pub fn cancel_cron_job(&self, id: &str) {
1159        self.cron().cancel_job(id);
1160    }
1161
1162    /// Subscribe to cron events. The TS API returns no unsubscribe; dropping the receiver is the
1163    /// equivalent. Each run emits `Fire` then `Complete`|`Error`. Mirrors TS `AgentOs.onCronEvent`.
1164    pub fn cron_events(&self) -> broadcast::Receiver<CronEvent> {
1165        self.cron().event_tx.subscribe()
1166    }
1167}
1168
1169fn ensure_cron_capacity(cron: &CronManager, id: &str) -> std::result::Result<(), ClientError> {
1170    if cron.jobs.contains(id) || cron.jobs.len() < crate::CRON_JOB_LIMIT {
1171        return Ok(());
1172    }
1173
1174    Err(ClientError::Sidecar(format!(
1175        "cron job limit exceeded: at most {} jobs can be scheduled per VM",
1176        crate::CRON_JOB_LIMIT
1177    )))
1178}
1179
1180fn register_cron_job(
1181    cron: &Arc<CronManager>,
1182    id: String,
1183    schedule: String,
1184    action: CronAction,
1185    overlap: CronOverlap,
1186    next_run: Option<DateTime<Utc>>,
1187    callback: crate::config::ScheduleCallback,
1188) -> std::result::Result<CronJobHandle, ClientError> {
1189    let _guard = cron.schedule_lock.lock();
1190    ensure_cron_capacity(cron, &id)?;
1191
1192    // If replacing an existing id, cancel the old driver-armed timer before scheduling the new one.
1193    // The default timer driver's handles are id-based, so cancelling after the new schedule would
1194    // cancel the replacement.
1195    if let Some((_, old)) = cron.jobs.remove(&id) {
1196        cron.driver.cancel(&old.handle);
1197    }
1198
1199    let handle = cron.driver.schedule(ScheduleEntry {
1200        id: id.clone(),
1201        schedule: schedule.clone(),
1202        callback,
1203    });
1204
1205    let state = CronJobState {
1206        schedule,
1207        action,
1208        overlap,
1209        last_run: parking_lot::Mutex::new(None),
1210        next_run: parking_lot::Mutex::new(next_run),
1211        run_count: std::sync::atomic::AtomicU64::new(0),
1212        running: AtomicBool::new(false),
1213        queued: AtomicBool::new(false),
1214        handle,
1215    };
1216
1217    let _ = cron.jobs.insert(id.clone(), state);
1218
1219    Ok(CronJobHandle {
1220        id,
1221        manager: Arc::clone(cron),
1222    })
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227    use super::{
1228        ensure_cron_capacity, register_cron_job, CronAction, CronJobState, CronManager,
1229        CronOverlap, ScheduleDriver, ScheduleEntry, ScheduleHandle,
1230    };
1231    use crate::CRON_JOB_LIMIT;
1232    use std::sync::atomic::AtomicBool;
1233    use std::sync::Arc;
1234
1235    #[derive(Default)]
1236    struct RecordingScheduleDriver {
1237        calls: parking_lot::Mutex<Vec<String>>,
1238    }
1239
1240    impl ScheduleDriver for RecordingScheduleDriver {
1241        fn schedule(&self, entry: ScheduleEntry) -> ScheduleHandle {
1242            self.calls.lock().push(format!("schedule:{}", entry.id));
1243            ScheduleHandle { id: entry.id }
1244        }
1245
1246        fn cancel(&self, handle: &ScheduleHandle) {
1247            self.calls.lock().push(format!("cancel:{}", handle.id));
1248        }
1249
1250        fn dispose(&self) {}
1251    }
1252
1253    fn dummy_state(id: String) -> CronJobState {
1254        CronJobState {
1255            schedule: "0 0 * * *".to_string(),
1256            action: CronAction::Callback {
1257                callback: Arc::new(|| Box::pin(async {})),
1258            },
1259            overlap: CronOverlap::Allow,
1260            last_run: parking_lot::Mutex::new(None),
1261            next_run: parking_lot::Mutex::new(None),
1262            run_count: std::sync::atomic::AtomicU64::new(0),
1263            running: AtomicBool::new(false),
1264            queued: AtomicBool::new(false),
1265            handle: ScheduleHandle { id },
1266        }
1267    }
1268
1269    #[test]
1270    fn cron_capacity_rejects_new_jobs_at_limit_but_allows_replacements() {
1271        let manager = CronManager::new(Arc::new(RecordingScheduleDriver::default()));
1272        for index in 0..CRON_JOB_LIMIT {
1273            let id = format!("job-{index}");
1274            assert!(
1275                manager.jobs.insert(id.clone(), dummy_state(id)).is_ok(),
1276                "seed cron job"
1277            );
1278        }
1279
1280        let error = ensure_cron_capacity(&manager, "overflow").expect_err("limit should reject");
1281        assert!(
1282            error.to_string().contains("cron job limit exceeded"),
1283            "unexpected limit error: {error}"
1284        );
1285        ensure_cron_capacity(&manager, "job-0").expect("replacement should be allowed");
1286    }
1287
1288    // ── Security: AOSCLIENT-P1-cron-exec (N-007 untrusted cron CronAction::Exec) ─────────────────
1289    //
1290    // Threat: an untrusted actor schedules a `CronAction::Exec { command, args }` whose `args`
1291    // carry data values (a path with spaces) or shell metacharacters (`$( )`, backticks). The
1292    // intent of a structured `(command, args)` action is that the args are passed VERBATIM as
1293    // argv elements — never re-split on whitespace, never re-evaluated by a shell.
1294    //
1295    // The bug (now fixed): `run_action`'s `CronAction::Exec` arm flattened the pair with
1296    // `format!("{} {}", command, args.join(" "))` and handed the STRING to `AgentOs::exec`, which
1297    // re-parsed it through `resolve_exec_command`. That round-trip (a) re-split `"a b"` into two
1298    // argv elements and (b)/(c) promoted `$(id)` / backtick elements to a real `sh -c` shell
1299    // evaluation. The fix sends the structured argv verbatim via `AgentOs::exec_argv`, bypassing
1300    // `resolve_exec_command` entirely.
1301    //
1302    // This test pins the fix: it computes the argv exactly as the fixed `CronAction::Exec` arm
1303    // does (verbatim `command` + `args`), and asserts the hostile elements survive intact. As a
1304    // negative control it also shows the OLD join+`resolve_exec_command` path corrupts them, so a
1305    // regression back to the flatten behavior fails this test.
1306    #[test]
1307    fn cron_exec_action_argv_is_not_shell_re_split_or_evaluated() {
1308        // The fixed `CronAction::Exec` arm passes `command` and `args` straight to `exec_argv`,
1309        // which sends them verbatim with no `resolve_exec_command` round-trip.
1310        fn cron_exec_argv(command: &str, args: &[&str]) -> (String, Vec<String>) {
1311            (
1312                command.to_string(),
1313                args.iter().map(|a| a.to_string()).collect(),
1314            )
1315        }
1316
1317        // The pre-fix flatten+re-parse path, kept here purely as a negative control.
1318        fn buggy_join_then_resolve(command: &str, args: &[&str]) -> (String, Vec<String>) {
1319            let joined = if args.is_empty() {
1320                command.to_string()
1321            } else {
1322                format!("{} {}", command, args.join(" "))
1323            };
1324            crate::command_line::resolve_exec_command(&joined).expect("line must resolve")
1325        }
1326
1327        // (a) A single argv element that contains a space MUST stay one argv element.
1328        let (cmd, args) = cron_exec_argv("printenv", &["a b"]);
1329        assert_eq!(
1330            (cmd.as_str(), args.as_slice()),
1331            ("printenv", &["a b".to_string()][..]),
1332            "N-007: structured argv element \"a b\" must survive as a single argv element"
1333        );
1334        // Negative control: the old path corrupts it by re-splitting on whitespace.
1335        let (_, buggy_args) = buggy_join_then_resolve("printenv", &["a b"]);
1336        assert_eq!(
1337            buggy_args,
1338            vec!["a".to_string(), "b".to_string()],
1339            "N-007 negative control: the old join+resolve path re-split \"a b\" into two argv elements"
1340        );
1341
1342        // (b) A command-substitution argv element MUST stay a literal argv element, never `sh -c`.
1343        let (cmd, args) = cron_exec_argv("printenv", &["$(id)"]);
1344        assert_eq!(
1345            (cmd.as_str(), args.as_slice()),
1346            ("printenv", &["$(id)".to_string()][..]),
1347            "N-007: command-substitution argv element \"$(id)\" must NOT be promoted to `sh -c`"
1348        );
1349        // Negative control: the old path routes the whole line through `sh -c`, evaluating `$(id)`.
1350        let (buggy_cmd, _) = buggy_join_then_resolve("printenv", &["$(id)"]);
1351        assert_eq!(
1352            buggy_cmd, "sh",
1353            "N-007 negative control: the old path promoted the `$(id)` line to a `sh -c` shell"
1354        );
1355
1356        // (c) A backtick argv element: same guarantee.
1357        let (cmd, args) = cron_exec_argv("printenv", &["`id`"]);
1358        assert_eq!(
1359            (cmd.as_str(), args.as_slice()),
1360            ("printenv", &["`id`".to_string()][..]),
1361            "N-007: backtick argv element \"`id`\" must NOT be promoted to `sh -c`"
1362        );
1363        let (buggy_cmd, _) = buggy_join_then_resolve("printenv", &["`id`"]);
1364        assert_eq!(
1365            buggy_cmd, "sh",
1366            "N-007 negative control: the old path promoted the backtick line to a `sh -c` shell"
1367        );
1368    }
1369
1370    // ── Security: AOSCLIENT-P2-cron-cap (N-008 cron job-limit flooding) ──────────────────────────
1371    //
1372    // Threat: an untrusted actor floods the cron registry to exhaust host scheduling resources.
1373    // The public `AgentOs::schedule_cron` registers through `register_cron_job` ->
1374    // `ensure_cron_capacity` (cron.rs:1162), which must cap distinct jobs at `CRON_JOB_LIMIT`
1375    // while still allowing an existing id to be REPLACED at the cap. `AgentOs::schedule_cron`
1376    // itself needs a live sidecar to construct, so we drive the exact same public registration
1377    // chokepoint (`register_cron_job`) the public method funnels into, with a recording driver.
1378    #[test]
1379    fn schedule_cron_public_path_rejects_jobs_beyond_cron_job_limit() {
1380        let driver = Arc::new(RecordingScheduleDriver::default());
1381        let manager = Arc::new(CronManager::new(driver.clone()));
1382
1383        let make_callback =
1384            || -> crate::config::ScheduleCallback { Arc::new(|| Box::pin(async {})) };
1385
1386        // Fill the registry to exactly CRON_JOB_LIMIT distinct ids through the public chokepoint.
1387        for index in 0..CRON_JOB_LIMIT {
1388            register_cron_job(
1389                &manager,
1390                format!("flood-{index}"),
1391                "0 0 * * *".to_string(),
1392                CronAction::Callback {
1393                    callback: make_callback(),
1394                },
1395                CronOverlap::Allow,
1396                None,
1397                make_callback(),
1398            )
1399            .unwrap_or_else(|err| panic!("seed job {index} should register: {err}"));
1400        }
1401        assert_eq!(manager.jobs.len(), CRON_JOB_LIMIT);
1402
1403        // The CRON_JOB_LIMIT+1-th DISTINCT id must be denied. (Match instead of `.expect_err()`
1404        // because the Ok type `CronJobHandle` does not implement Debug.)
1405        let overflow = match register_cron_job(
1406            &manager,
1407            "flood-overflow".to_string(),
1408            "0 0 * * *".to_string(),
1409            CronAction::Callback {
1410                callback: make_callback(),
1411            },
1412            CronOverlap::Allow,
1413            None,
1414            make_callback(),
1415        ) {
1416            Ok(_) => {
1417                panic!("AOSCLIENT-P2-cron-cap: the job beyond CRON_JOB_LIMIT must be rejected")
1418            }
1419            Err(err) => err,
1420        };
1421        assert!(
1422            overflow.to_string().contains("cron job limit exceeded"),
1423            "AOSCLIENT-P2-cron-cap: overflow rejection must report the cron job limit, got: {overflow}"
1424        );
1425        assert_eq!(
1426            manager.jobs.len(),
1427            CRON_JOB_LIMIT,
1428            "AOSCLIENT-P2-cron-cap: a rejected overflow job must not be inserted"
1429        );
1430
1431        // Replacing an EXISTING id while at the cap must still succeed (replace, not grow).
1432        register_cron_job(
1433            &manager,
1434            "flood-0".to_string(),
1435            "0 1 * * *".to_string(),
1436            CronAction::Callback {
1437                callback: make_callback(),
1438            },
1439            CronOverlap::Allow,
1440            None,
1441            make_callback(),
1442        )
1443        .expect("AOSCLIENT-P2-cron-cap: replacing an existing id at the cap must be allowed");
1444        assert_eq!(
1445            manager.jobs.len(),
1446            CRON_JOB_LIMIT,
1447            "AOSCLIENT-P2-cron-cap: replacing an existing id must not grow the registry past the cap"
1448        );
1449    }
1450
1451    #[test]
1452    fn cron_replacement_cancels_old_timer_before_scheduling_new_timer() {
1453        let driver = Arc::new(RecordingScheduleDriver::default());
1454        let manager = Arc::new(CronManager::new(driver.clone()));
1455        let callback: crate::config::ScheduleCallback = Arc::new(|| Box::pin(async {}));
1456
1457        register_cron_job(
1458            &manager,
1459            "same-id".to_string(),
1460            "0 0 * * *".to_string(),
1461            CronAction::Callback {
1462                callback: callback.clone(),
1463            },
1464            CronOverlap::Allow,
1465            None,
1466            callback.clone(),
1467        )
1468        .expect("initial schedule");
1469        register_cron_job(
1470            &manager,
1471            "same-id".to_string(),
1472            "0 1 * * *".to_string(),
1473            CronAction::Callback { callback },
1474            CronOverlap::Allow,
1475            None,
1476            Arc::new(|| Box::pin(async {})),
1477        )
1478        .expect("replacement schedule");
1479
1480        assert_eq!(
1481            *driver.calls.lock(),
1482            vec!["schedule:same-id", "cancel:same-id", "schedule:same-id"]
1483        );
1484        assert_eq!(manager.jobs.len(), 1);
1485    }
1486}