Skip to main content

kanade_shared/wire/
command.rs

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