Skip to main content

kanade_shared/wire/
command.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use super::Staleness;
5
6#[derive(Serialize, Deserialize, Debug, Clone)]
7pub struct Command {
8    pub id: String,
9    pub version: String,
10    pub request_id: String,
11    pub job_id: Option<String>,
12    pub shell: Shell,
13    pub script: String,
14    pub timeout_secs: u64,
15    pub jitter_secs: Option<u64>,
16    /// Which (token, session) combination the agent should launch the
17    /// child process under (v0.21). Defaults to [`RunAs::System`] for
18    /// back-compat with pre-v0.21 backends that don't send this field.
19    #[serde(default)]
20    pub run_as: RunAs,
21    /// Working directory for the spawned child (v0.21.1). `None` ⇒
22    /// inherit the agent's cwd. Pre-v0.21.1 wire payloads omit this
23    /// field and parse fine via `#[serde(default)]`.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub cwd: Option<String>,
26    /// Absolute time after which the agent should refuse to run
27    /// this Command (v0.22). Set by the scheduler from
28    /// `Schedule.starting_deadline` (humantime) measured against
29    /// the cron tick time. `None` ⇒ no deadline, run whenever
30    /// received (default for ad-hoc `kanade exec` + back-compat
31    /// for pre-v0.22 wire). The agent stamps a synthetic
32    /// `ExecResult { exit_code: 125, stderr: "skipped: deadline
33    /// expired ..." }` when it skips, so the operator sees the
34    /// outcome on the Results / Dashboard pages instead of silence.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub deadline_at: Option<DateTime<Utc>>,
37    /// v0.26: Manifest-declared Layer 2 staleness policy
38    /// (see SPEC.md §2.6.2). Forwarded from `Manifest.staleness` so
39    /// the agent can evaluate it at fire time without re-fetching the
40    /// Manifest from `BUCKET_JOBS`. Pre-v0.26 wire omits this and
41    /// `#[serde(default)]` falls back to `Staleness::Cached`, matching
42    /// pre-v0.26 behaviour (silently use cached KV values).
43    #[serde(default)]
44    pub staleness: Staleness,
45}
46
47#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
48#[serde(rename_all = "lowercase")]
49pub enum Shell {
50    Powershell,
51    Cmd,
52}
53
54/// **Token + session combination** the agent uses to spawn a job's
55/// child process. Two orthogonal axes — *whose privileges* and *which
56/// session* — collapse into three meaningful combinations:
57///
58/// | variant            | session                | privileges  | GUI |
59/// |--------------------|------------------------|-------------|-----|
60/// | `System` (default) | Session 0 (services)   | LocalSystem | ❌  |
61/// | `User`             | active console session | logged-in user (UAC-filtered when admin) | ✅ |
62/// | `SystemGui`        | active console session | LocalSystem | ✅  |
63///
64/// `SystemGui` is the "PsExec `-i -s`" pattern: the agent duplicates
65/// its own SYSTEM token and rewrites `TokenSessionId` to the user's
66/// console session, then launches with that hybrid token — useful
67/// when an installer needs admin power *and* needs the user to see
68/// its UI.
69#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
70#[serde(rename_all = "snake_case")]
71pub enum RunAs {
72    /// LocalSystem privileges in Session 0. No GUI. Historical
73    /// default — every pre-v0.21 job ran this way.
74    #[default]
75    System,
76    /// The currently-logged-in console user's identity, in their
77    /// session. Can write HKCU / %APPDATA% / show GUI to the user.
78    /// Privileges are whatever the user has (admin users get the
79    /// UAC-filtered limited token, not the elevated one).
80    User,
81    /// LocalSystem privileges in the user's session — admin power
82    /// with GUI visibility. Niche but real (force-restart dialogs,
83    /// admin installers with progress UI).
84    SystemGui,
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    fn sample_command() -> Command {
92        Command {
93            id: "echo-test".into(),
94            version: "1.0.0".into(),
95            request_id: "req-1".into(),
96            job_id: Some("dep-1".into()),
97            shell: Shell::Powershell,
98            script: "echo hi".into(),
99            timeout_secs: 30,
100            jitter_secs: Some(5),
101            run_as: RunAs::System,
102            cwd: None,
103            deadline_at: None,
104            staleness: Staleness::Cached,
105        }
106    }
107
108    #[test]
109    fn shell_serialises_lowercase() {
110        let json = serde_json::to_string(&Shell::Powershell).unwrap();
111        assert_eq!(json, "\"powershell\"");
112        let json = serde_json::to_string(&Shell::Cmd).unwrap();
113        assert_eq!(json, "\"cmd\"");
114    }
115
116    #[test]
117    fn run_as_serialises_snake_case() {
118        for (mode, expected) in [
119            (RunAs::System, "\"system\""),
120            (RunAs::User, "\"user\""),
121            (RunAs::SystemGui, "\"system_gui\""),
122        ] {
123            let json = serde_json::to_string(&mode).unwrap();
124            assert_eq!(json, expected, "serialise {mode:?}");
125            let back: RunAs = serde_json::from_str(expected).unwrap();
126            assert_eq!(back, mode, "round-trip {expected}");
127        }
128    }
129
130    #[test]
131    fn run_as_defaults_to_system() {
132        assert_eq!(RunAs::default(), RunAs::System);
133    }
134
135    #[test]
136    fn command_round_trips_through_json() {
137        let orig = sample_command();
138        let json = serde_json::to_string(&orig).expect("encode");
139        let decoded: Command = serde_json::from_str(&json).expect("decode");
140        assert_eq!(decoded.id, orig.id);
141        assert_eq!(decoded.version, orig.version);
142        assert_eq!(decoded.request_id, orig.request_id);
143        assert_eq!(decoded.job_id, orig.job_id);
144        assert_eq!(decoded.shell, orig.shell);
145        assert_eq!(decoded.script, orig.script);
146        assert_eq!(decoded.timeout_secs, orig.timeout_secs);
147        assert_eq!(decoded.jitter_secs, orig.jitter_secs);
148        assert_eq!(decoded.run_as, orig.run_as);
149    }
150
151    #[test]
152    fn command_round_trips_each_run_as_variant() {
153        for mode in [RunAs::System, RunAs::User, RunAs::SystemGui] {
154            let cmd = Command {
155                run_as: mode,
156                ..sample_command()
157            };
158            let json = serde_json::to_string(&cmd).unwrap();
159            let back: Command = serde_json::from_str(&json).unwrap();
160            assert_eq!(back.run_as, mode);
161        }
162    }
163
164    #[test]
165    fn command_accepts_missing_optional_fields() {
166        let json = r#"{
167          "id": "x",
168          "version": "1.0.0",
169          "request_id": "r",
170          "shell": "cmd",
171          "script": "echo",
172          "timeout_secs": 5
173        }"#;
174        let cmd: Command = serde_json::from_str(json).expect("decode");
175        assert!(cmd.job_id.is_none());
176        assert!(cmd.jitter_secs.is_none());
177        assert_eq!(cmd.shell, Shell::Cmd);
178        // Pre-v0.21 wire payloads omit run_as → falls back to System.
179        assert_eq!(cmd.run_as, RunAs::System);
180        // Pre-v0.21.1 omit cwd → None (= inherit agent cwd).
181        assert!(cmd.cwd.is_none());
182        // Pre-v0.22 omit deadline_at → None (= no deadline).
183        assert!(cmd.deadline_at.is_none());
184    }
185
186    #[test]
187    fn command_deadline_at_round_trips() {
188        use chrono::TimeZone;
189        let deadline = Utc.with_ymd_and_hms(2026, 5, 18, 9, 30, 0).unwrap();
190        let cmd = Command {
191            deadline_at: Some(deadline),
192            ..sample_command()
193        };
194        let json = serde_json::to_string(&cmd).unwrap();
195        let back: Command = serde_json::from_str(&json).unwrap();
196        assert_eq!(back.deadline_at, Some(deadline));
197    }
198}