Skip to main content

kanade_shared/wire/
command.rs

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