kanade_shared/wire/command.rs
1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use super::Staleness;
5use crate::manifest::EmitConfig;
6
7#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
8pub struct Command {
9 pub id: String,
10 pub version: String,
11 pub request_id: String,
12 /// v0.29 / Issue #19: the deployment / scheduler-fire UUID this
13 /// Command belongs to. Forwarded into `ExecResult.exec_id` by the
14 /// agent so the projector can attribute results back to the
15 /// originating `executions` row. `None` for ad-hoc `kanade run`
16 /// (no deployment row exists). Pre-v0.29 wire used the field name
17 /// `job_id` for this same value — `serde(alias)` keeps old
18 /// publishes in STREAM_EXEC decodable across the upgrade window.
19 #[serde(alias = "job_id")]
20 pub exec_id: Option<String>,
21 pub shell: Shell,
22 /// Inline script body, OR empty when [`script_object`] is set.
23 /// Mutually exclusive with `script_object` at the wire level —
24 /// backend builders fill one or the other (never both) and the
25 /// agent's resolver picks the populated one. Pre-v0.43 wire
26 /// always carries this populated.
27 ///
28 /// [`script_object`]: Self::script_object
29 pub script: String,
30 /// SPEC §2.4.1 / yukimemi/kanade#210: Object Store reference
31 /// (`<name>/<version>` key into `OBJECT_SCRIPTS`). When set,
32 /// the agent fetches the body via `script_cache` and verifies
33 /// its sha256 against [`script_object_sha256`] before launching.
34 /// `None` ⇒ inline `script` carries the body (legacy + the
35 /// majority of jobs).
36 ///
37 /// [`script_object_sha256`]: Self::script_object_sha256
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub script_object: Option<String>,
40 /// Hex-encoded sha256 of the bytes the operator approved at
41 /// Command-build time. Required when [`script_object`] is set;
42 /// the agent treats a mismatch on fetch as "operator
43 /// re-uploaded the script between exec submission and agent
44 /// fire" and aborts the run rather than silently executing the
45 /// new bytes. Pre-v0.43 wire omits this; the resolver path
46 /// requires both fields to be `Some`.
47 ///
48 /// [`script_object`]: Self::script_object
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub script_object_sha256: Option<String>,
51 pub timeout_secs: u64,
52 pub jitter_secs: Option<u64>,
53 /// Which (token, session) combination the agent should launch the
54 /// child process under (v0.21). Defaults to [`RunAs::System`] for
55 /// back-compat with pre-v0.21 backends that don't send this field.
56 #[serde(default)]
57 pub run_as: RunAs,
58 /// Working directory for the spawned child (v0.21.1). `None` ⇒
59 /// inherit the agent's cwd. Pre-v0.21.1 wire payloads omit this
60 /// field and parse fine via `#[serde(default)]`.
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub cwd: Option<String>,
63 /// Absolute time after which the agent should refuse to run
64 /// this Command (v0.22). Set by the scheduler from
65 /// `Schedule.starting_deadline` (humantime) measured against
66 /// the cron tick time. `None` ⇒ no deadline, run whenever
67 /// received (default for ad-hoc `kanade exec` + back-compat
68 /// for pre-v0.22 wire). The agent stamps a synthetic
69 /// `ExecResult { exit_code: 125, stderr: "skipped: deadline
70 /// expired ..." }` when it skips, so the operator sees the
71 /// outcome on the Results / Dashboard pages instead of silence.
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub deadline_at: Option<DateTime<Utc>>,
74 /// v0.26: Manifest-declared Layer 2 staleness policy
75 /// (see SPEC.md §2.6.2). Forwarded from `Manifest.staleness` so
76 /// the agent can evaluate it at fire time without re-fetching the
77 /// Manifest from `BUCKET_JOBS`. Pre-v0.26 wire omits this and
78 /// `#[serde(default)]` falls back to `Staleness::Cached`, matching
79 /// pre-v0.26 behaviour (silently use cached KV values).
80 #[serde(default)]
81 pub staleness: Staleness,
82 /// Issue #246: forwarded from `Manifest.emit` so the agent
83 /// doesn't have to re-fetch the manifest at fire time. When
84 /// `Some` and `EmitKind::Events`, the agent parses script
85 /// stdout as NDJSON `ObsEvent` and publishes each line on
86 /// `obs.<pc_id>`. Pre-#246 wire omits this; the `#[serde(default)]`
87 /// fallback to `None` preserves prior behaviour (stdout flows
88 /// to `ExecResult` unchanged).
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub emit: Option<EmitConfig>,
91}
92
93#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
94#[serde(rename_all = "lowercase")]
95pub enum Shell {
96 Powershell,
97 Cmd,
98}
99
100/// **Token + session combination** the agent uses to spawn a job's
101/// child process. Two orthogonal axes — *whose privileges* and *which
102/// session* — collapse into three meaningful combinations:
103///
104/// | variant | session | privileges | GUI |
105/// |--------------------|------------------------|-------------|-----|
106/// | `System` (default) | Session 0 (services) | LocalSystem | ❌ |
107/// | `User` | active console session | logged-in user (UAC-filtered when admin) | ✅ |
108/// | `SystemGui` | active console session | LocalSystem | ✅ |
109///
110/// `SystemGui` is the "PsExec `-i -s`" pattern: the agent duplicates
111/// its own SYSTEM token and rewrites `TokenSessionId` to the user's
112/// console session, then launches with that hybrid token — useful
113/// when an installer needs admin power *and* needs the user to see
114/// its UI.
115#[derive(
116 Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
117)]
118#[serde(rename_all = "snake_case")]
119pub enum RunAs {
120 /// LocalSystem privileges in Session 0. No GUI. Historical
121 /// default — every pre-v0.21 job ran this way.
122 #[default]
123 System,
124 /// The currently-logged-in console user's identity, in their
125 /// session. Can write HKCU / %APPDATA% / show GUI to the user.
126 /// Privileges are whatever the user has (admin users get the
127 /// UAC-filtered limited token, not the elevated one).
128 User,
129 /// LocalSystem privileges in the user's session — admin power
130 /// with GUI visibility. Niche but real (force-restart dialogs,
131 /// admin installers with progress UI).
132 SystemGui,
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 fn sample_command() -> Command {
140 Command {
141 id: "echo-test".into(),
142 version: "1.0.0".into(),
143 request_id: "req-1".into(),
144 exec_id: Some("dep-1".into()),
145 shell: Shell::Powershell,
146 script: "echo hi".into(),
147 script_object: None,
148 script_object_sha256: None,
149 timeout_secs: 30,
150 jitter_secs: Some(5),
151 run_as: RunAs::System,
152 cwd: None,
153 deadline_at: None,
154 staleness: Staleness::Cached,
155 emit: None,
156 }
157 }
158
159 #[test]
160 fn shell_serialises_lowercase() {
161 let json = serde_json::to_string(&Shell::Powershell).unwrap();
162 assert_eq!(json, "\"powershell\"");
163 let json = serde_json::to_string(&Shell::Cmd).unwrap();
164 assert_eq!(json, "\"cmd\"");
165 }
166
167 #[test]
168 fn run_as_serialises_snake_case() {
169 for (mode, expected) in [
170 (RunAs::System, "\"system\""),
171 (RunAs::User, "\"user\""),
172 (RunAs::SystemGui, "\"system_gui\""),
173 ] {
174 let json = serde_json::to_string(&mode).unwrap();
175 assert_eq!(json, expected, "serialise {mode:?}");
176 let back: RunAs = serde_json::from_str(expected).unwrap();
177 assert_eq!(back, mode, "round-trip {expected}");
178 }
179 }
180
181 #[test]
182 fn run_as_defaults_to_system() {
183 assert_eq!(RunAs::default(), RunAs::System);
184 }
185
186 #[test]
187 fn command_round_trips_through_json() {
188 let orig = sample_command();
189 let json = serde_json::to_string(&orig).expect("encode");
190 let decoded: Command = serde_json::from_str(&json).expect("decode");
191 assert_eq!(decoded.id, orig.id);
192 assert_eq!(decoded.version, orig.version);
193 assert_eq!(decoded.request_id, orig.request_id);
194 assert_eq!(decoded.exec_id, orig.exec_id);
195 assert_eq!(decoded.shell, orig.shell);
196 assert_eq!(decoded.script, orig.script);
197 assert_eq!(decoded.timeout_secs, orig.timeout_secs);
198 assert_eq!(decoded.jitter_secs, orig.jitter_secs);
199 assert_eq!(decoded.run_as, orig.run_as);
200 }
201
202 #[test]
203 fn command_round_trips_each_run_as_variant() {
204 for mode in [RunAs::System, RunAs::User, RunAs::SystemGui] {
205 let cmd = Command {
206 run_as: mode,
207 ..sample_command()
208 };
209 let json = serde_json::to_string(&cmd).unwrap();
210 let back: Command = serde_json::from_str(&json).unwrap();
211 assert_eq!(back.run_as, mode);
212 }
213 }
214
215 #[test]
216 fn command_accepts_missing_optional_fields() {
217 let json = r#"{
218 "id": "x",
219 "version": "1.0.0",
220 "request_id": "r",
221 "shell": "cmd",
222 "script": "echo",
223 "timeout_secs": 5
224 }"#;
225 let cmd: Command = serde_json::from_str(json).expect("decode");
226 assert!(cmd.exec_id.is_none());
227 assert!(cmd.jitter_secs.is_none());
228 assert_eq!(cmd.shell, Shell::Cmd);
229 // Pre-v0.21 wire payloads omit run_as → falls back to System.
230 assert_eq!(cmd.run_as, RunAs::System);
231 // Pre-v0.21.1 omit cwd → None (= inherit agent cwd).
232 assert!(cmd.cwd.is_none());
233 // Pre-v0.22 omit deadline_at → None (= no deadline).
234 assert!(cmd.deadline_at.is_none());
235 // Pre-v0.43 wire omits both script_object fields — agent
236 // falls back to the inline `script` body.
237 assert!(cmd.script_object.is_none());
238 assert!(cmd.script_object_sha256.is_none());
239 }
240
241 #[test]
242 fn command_round_trips_script_object_fields() {
243 // yukimemi/kanade#210: backend builds Commands carrying an
244 // OBJECT_SCRIPTS reference + the operator-approved digest;
245 // agent resolves on fetch. Both fields must survive a JSON
246 // round-trip with the same shape.
247 let cmd = Command {
248 script: String::new(),
249 script_object: Some("cleanup-disk-temp/1.0.1".into()),
250 script_object_sha256: Some(
251 "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef".into(),
252 ),
253 ..sample_command()
254 };
255 let json = serde_json::to_string(&cmd).expect("encode");
256 let back: Command = serde_json::from_str(&json).expect("decode");
257 assert_eq!(back.script, "");
258 assert_eq!(
259 back.script_object.as_deref(),
260 Some("cleanup-disk-temp/1.0.1")
261 );
262 assert_eq!(
263 back.script_object_sha256.as_deref(),
264 Some("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
265 );
266 }
267
268 #[test]
269 fn command_decodes_legacy_job_id_field_as_exec_id() {
270 // v0.29 / Issue #19: Commands sitting in STREAM_EXEC published
271 // by a pre-v0.29 backend still carry the field named `job_id`.
272 // The `#[serde(alias = "job_id")]` on `exec_id` keeps them
273 // decodable through the upgrade window so the agent doesn't
274 // start dropping replays on first boot of a new binary.
275 let json = r#"{
276 "id": "x",
277 "version": "1.0.0",
278 "request_id": "r",
279 "job_id": "legacy-exec-uuid",
280 "shell": "powershell",
281 "script": "echo",
282 "timeout_secs": 5
283 }"#;
284 let cmd: Command = serde_json::from_str(json).expect("decode legacy");
285 assert_eq!(cmd.exec_id.as_deref(), Some("legacy-exec-uuid"));
286 }
287
288 #[test]
289 fn command_deadline_at_round_trips() {
290 use chrono::TimeZone;
291 let deadline = Utc.with_ymd_and_hms(2026, 5, 18, 9, 30, 0).unwrap();
292 let cmd = Command {
293 deadline_at: Some(deadline),
294 ..sample_command()
295 };
296 let json = serde_json::to_string(&cmd).unwrap();
297 let back: Command = serde_json::from_str(&json).unwrap();
298 assert_eq!(back.deadline_at, Some(deadline));
299 }
300}