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