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