microvms_protocol/exec.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Exec wire types: the bodies, queries, and SSE event payloads of `/v1/exec/*`.
3//!
4//! Every type here derives both halves of serde. The daemon needs only one half of
5//! each and a client needs the other, and a type that carries one half is a type the
6//! other side has to hand-write — which is the drift this crate exists to prevent.
7//! The published `docs/schema.json` is generated from these same attributes under
8//! both contracts, so a shape change is a schema diff rather than a surprise.
9
10use std::borrow::Cow;
11use std::collections::HashMap;
12
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15
16/// Where an exec sits in its lifecycle. Mirrors `ExecPhase` in the model crate.
17///
18/// `JsonSchema` rides along with `Serialize` on every type from here down that
19/// crosses the wire. schemars reads the same `#[serde(...)]` attributes serde
20/// does, so the published schema describes what the daemon actually emits — the
21/// `rename_all` below is the reason this matters rather than a formality.
22#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
23#[serde(rename_all = "snake_case")]
24pub enum Phase {
25 /// Child spawned, still running (or its pipes still held by a grandchild).
26 Running,
27 /// Child exited and output is buffered and readable.
28 Exited,
29 /// Caller acked; output has been released and the entry awaits collection.
30 Acked,
31}
32
33impl Phase {
34 /// Every phase, in lifecycle order.
35 ///
36 /// Public because a client that publishes the closed set — both bindings do, in
37 /// their `session_constants` — needs the list from the type rather than a spelled-out
38 /// copy that goes stale the first time a phase is added. The round-trip test below
39 /// holds `ALL` complete by exhaustive match.
40 pub const ALL: [Phase; 3] = [Phase::Running, Phase::Exited, Phase::Acked];
41
42 /// The wire spelling — the exact string serde writes under `rename_all` above.
43 ///
44 /// Here rather than in each client because two bindings each grew their own
45 /// three-arm match over this enum; a variant renamed on the wire must change
46 /// exactly one table, and the test below is what keeps this one equal to serde's.
47 pub const fn as_str(self) -> &'static str {
48 match self {
49 Phase::Running => "running",
50 Phase::Exited => "exited",
51 Phase::Acked => "acked",
52 }
53 }
54}
55
56/// Captured output and exit status of a finished exec.
57#[derive(Clone, Debug, Default, Deserialize, JsonSchema, Serialize)]
58pub struct Outcome {
59 /// Exit code, or `None` when the child died to a signal.
60 pub exit_code: Option<i32>,
61 /// Signal number that killed the child, when one did.
62 pub signal: Option<i32>,
63 /// The daemon execution deadline fired. Absent on older daemons, which cannot
64 /// distinguish deadline expiry from other signal deaths.
65 #[serde(default)]
66 pub timed_out: bool,
67 pub stdout: String,
68 pub stderr: String,
69 /// Set when either stream hit `max_output_bytes` and was cut. An explicit
70 /// flag rather than a sentinel string in the output: a marker inside the
71 /// bytes is indistinguishable from output that happens to contain it.
72 pub truncated: bool,
73 /// Set when the post-exit linger deadline expired with the pipes still open,
74 /// meaning some grandchild is alive and may write more that nobody will see.
75 /// Reported rather than hidden, because a harness that sees empty output from
76 /// a command it knows produced some needs to be able to tell why.
77 pub writers_may_be_alive: bool,
78}
79
80/// Which pipe a streamed chunk came from. Both share one offset space, so a
81/// client holds one cursor rather than two that can disagree about ordering.
82#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
83#[serde(rename_all = "snake_case")]
84pub enum StreamKind {
85 Stdout,
86 Stderr,
87}
88
89impl StreamKind {
90 /// Both kinds, in the order the daemon documents them.
91 ///
92 /// Same reason as [`Phase::ALL`]: the bindings publish this closed set, and a
93 /// list they spell themselves is a list the enum can outgrow.
94 pub const ALL: [StreamKind; 2] = [StreamKind::Stdout, StreamKind::Stderr];
95
96 /// The wire spelling — the exact string serde writes under `rename_all` above.
97 pub const fn as_str(self) -> &'static str {
98 match self {
99 StreamKind::Stdout => "stdout",
100 StreamKind::Stderr => "stderr",
101 }
102 }
103}
104
105/// A user or group as a start request names it: a numeric id, or a name the daemon
106/// resolves against the guest's `/etc/passwd` or `/etc/group`.
107///
108/// Untagged, so the wire carries a bare JSON integer or a bare JSON string and nothing
109/// else. An integer serializes exactly as the `u32` this field used to be, which is what
110/// keeps a client that only ever sends ids byte-compatible with a daemon that predates
111/// names. A string sent to such a daemon is refused as `malformed_request` rather than
112/// misread.
113///
114/// A string that names no row and is all ASCII digits is read as that id, the way
115/// `docker exec -u 1000` reads it: a harness that carries a uid as a string reaches the
116/// same account an integer would.
117#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
118#[serde(untagged)]
119pub enum NameOrId {
120 /// A numeric uid or gid, applied as given.
121 Id(u32),
122 /// A user or group name, resolved in the guest before anything is spawned.
123 Name(String),
124}
125
126impl From<u32> for NameOrId {
127 fn from(id: u32) -> Self {
128 NameOrId::Id(id)
129 }
130}
131
132impl From<&str> for NameOrId {
133 fn from(name: &str) -> Self {
134 NameOrId::Name(name.to_string())
135 }
136}
137
138impl From<String> for NameOrId {
139 fn from(name: String) -> Self {
140 NameOrId::Name(name)
141 }
142}
143
144impl std::str::FromStr for NameOrId {
145 type Err = std::convert::Infallible;
146
147 /// A command-line spelling: all digits is an id, anything else is a name.
148 ///
149 /// An id is sent as an integer rather than as its digits so a daemon that predates
150 /// names still accepts it.
151 fn from_str(raw: &str) -> Result<Self, Self::Err> {
152 Ok(match raw.parse::<u32>() {
153 Ok(id) if raw.bytes().all(|b| b.is_ascii_digit()) => NameOrId::Id(id),
154 _ => NameOrId::Name(raw.to_string()),
155 })
156 }
157}
158
159impl std::fmt::Display for NameOrId {
160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 match self {
162 NameOrId::Id(id) => write!(f, "{id}"),
163 NameOrId::Name(name) => f.write_str(name),
164 }
165 }
166}
167
168/// How a start request's `command` is run: `false` (an argv), `true` (`/bin/sh -c`), or
169/// the name of a shell the daemon resolves and runs as `<shell> -c`.
170///
171/// Untagged for [`NameOrId`]'s reason: a boolean serializes exactly as the field always
172/// did, so an old daemon keeps accepting it, and a string sent to an old daemon is refused
173/// rather than misread.
174#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
175#[serde(untagged)]
176pub enum Shell {
177 /// `false` execs `command` as an argv; `true` runs it under `/bin/sh -c`.
178 Flag(bool),
179 /// A shell name such as `"bash"` (resolved on the child's `PATH`, the image's `PATH`,
180 /// `/bin` and `/usr/bin`) or an absolute path to one.
181 Named(String),
182}
183
184impl Default for Shell {
185 fn default() -> Self {
186 Shell::Flag(false)
187 }
188}
189
190impl From<bool> for Shell {
191 fn from(flag: bool) -> Self {
192 Shell::Flag(flag)
193 }
194}
195
196impl From<&str> for Shell {
197 fn from(name: &str) -> Self {
198 Shell::Named(name.to_string())
199 }
200}
201
202impl From<String> for Shell {
203 fn from(name: String) -> Self {
204 Shell::Named(name)
205 }
206}
207
208impl Shell {
209 /// Whether `command` is a script for a shell rather than an argv.
210 pub fn is_shell(&self) -> bool {
211 !matches!(self, Shell::Flag(false))
212 }
213}
214
215/// A start request. `command` is either an argv array or, with `shell` set, a single
216/// script string.
217#[derive(Debug, Deserialize, JsonSchema, Serialize)]
218#[non_exhaustive]
219pub struct StartRequest {
220 /// Caller-minted idempotency key. Harbor retries, and a retry must not
221 /// produce a second child.
222 pub exec_id: String,
223 /// argv when `shell` is false, or the script when it is set.
224 pub command: Vec<String>,
225 /// `false`, `true` (`/bin/sh -c`), or a shell name such as `"bash"`. A name the guest
226 /// cannot resolve answers 400 `unknown_shell` before anything is spawned.
227 #[serde(default)]
228 pub shell: Shell,
229 /// Omitted means inherit the daemon's working directory. See the module docs.
230 #[serde(default)]
231 pub cwd: Option<String>,
232 #[serde(default)]
233 pub env: HashMap<String, String>,
234 /// The user to demote to: a numeric uid, or a name resolved against the guest's
235 /// `/etc/passwd`. Optional; omitted means the image's `AGENTD_DEFAULT_USER`, resolved to
236 /// the passwd row its name or uid matches, with that row's primary gid, or the daemon's
237 /// own user when the image sets none. A name the guest does not have, or a default with
238 /// no row, answers 400 `unknown_user` before anything is spawned.
239 ///
240 /// A user with a passwd row, however it was given, gets `HOME`, `USER` and `LOGNAME`
241 /// from the row, beneath the launch environment and `env`. A user given by name also
242 /// gets the row's primary gid when `group` is omitted; a numeric uid keeps the
243 /// daemon's group, as it always did.
244 #[serde(default)]
245 pub user: Option<NameOrId>,
246 /// The group to demote to: a numeric gid, or a name resolved against `/etc/group`. A
247 /// name the guest does not have answers 400 `unknown_group`.
248 #[serde(default)]
249 pub group: Option<NameOrId>,
250 /// Wall-clock budget. Validated before the child spawns — the predecessor
251 /// raised on a bad value inside the waiter thread, by which point the child
252 /// was already running and became an orphan.
253 #[serde(default)]
254 pub timeout_sec: Option<f64>,
255 /// Whether to give the child a writable stdin pipe. Defaults to false, which
256 /// keeps `Stdio::null()`.
257 ///
258 /// Opt-in rather than always-on, and not only for tidiness: a child holding an
259 /// open stdin pipe nobody will ever write to is a child that blocks forever
260 /// the first time it reads. `/bin/sh` reading a script from stdin, `git`
261 /// deciding it can prompt, any tool that probes for input — all of them behave
262 /// differently against a pipe than against `/dev/null`. Every existing caller
263 /// gets today's behavior by not setting this.
264 #[serde(default)]
265 pub stdin: bool,
266 /// Whether to signal the exec's whole process group once its own child exits.
267 ///
268 /// Off by default, and the default is the contract this daemon has always had:
269 /// a backgrounded grandchild that inherited the output pipe keeps running and
270 /// keeps writing, and the linger deadline is what bounds how long the daemon
271 /// waits on it. That is the "backgrounded server keeps logging" guarantee, and
272 /// callers rely on it. Set to `true`, the daemon runs the same SIGTERM-then-
273 /// SIGKILL escalation `POST /v1/exec/{id}/kill` uses against the group as soon
274 /// as the child's exit is observed, so nothing the command left behind outlives
275 /// it and the linger sees EOF rather than `writers_may_be_alive`. A timed-out
276 /// exec is already escalated and is not escalated twice.
277 #[serde(default)]
278 pub reap_group_on_exit: bool,
279 /// Power off the guest after this exec's original process group has no live members.
280 /// Off by default. Requires advertised daemon support and its CAP_SYS_BOOT privilege.
281 /// Other work in the VM ends too; the start acknowledgment and exit record may be lost.
282 /// Processes that leave the group are outside this condition. This does not keep an
283 /// idle guest awake, and a terminated state does not establish when billing stops.
284 #[serde(default, skip_serializing_if = "is_false")]
285 pub poweroff_on_exit: bool,
286 /// Whether the child's environment starts from the image's `ENV`.
287 ///
288 /// Off by default, which keeps the property the daemon has always had: nothing from
289 /// its own environment reaches a child. Set, the environment the daemon inherited as
290 /// the container `CMD`, minus every `AGENTD_*` variable, is the lowest layer, under
291 /// the passwd identity, the launch environment and `env`. The agent token is never in
292 /// that snapshot: it arrives in the run hook, after the snapshot is taken.
293 ///
294 /// A daemon that predates this field ignores it, as serde ignores any unknown key; a
295 /// caller that depends on it can read `image_env_keys` on `GET /v1/health`, which such
296 /// a daemon does not report.
297 #[serde(default)]
298 pub inherit_image_env: bool,
299}
300
301/// `POST /v1/exec/{id}/stdin` body.
302///
303/// Both fields optional and both meaningful together: a final chunk plus EOF in
304/// one request is the common case for feeding a prompt, and forcing two round
305/// trips would leave a window where the child has the bytes but not the EOF that
306/// tells it the input is complete.
307#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
308pub struct StdinRequest {
309 /// Base64 so arbitrary bytes survive JSON. A JSON string cannot carry
310 /// non-UTF-8, and stdin is bytes.
311 #[serde(default)]
312 pub data_b64: Option<String>,
313 /// `"eof"` closes the pipe after any `data_b64` is written. Named rather than
314 /// a bare boolean so the field has somewhere to grow.
315 #[serde(default)]
316 pub signal: Option<String>,
317}
318
319/// `POST /v1/exec/{id}/stdin` response.
320///
321/// `pub` rather than private, like every other type in this module the schema
322/// route publishes: the generator names them by type, so a response shape that
323/// stays private is a shape a consumer cannot be told about.
324#[derive(Debug, Deserialize, JsonSchema, Serialize)]
325pub struct StdinResponse {
326 pub exec_id: String,
327 pub written: usize,
328 pub eof: bool,
329}
330
331/// `GET /v1/exec/{id}/stream` query.
332#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
333pub struct StreamQuery {
334 /// Byte offset to resume from. Absent means 0, i.e. everything still in the
335 /// replay window.
336 #[serde(default)]
337 pub offset: Option<u64>,
338}
339
340/// One `output` SSE event.
341#[derive(Debug, Deserialize, JsonSchema, Serialize)]
342pub struct OutputEvent {
343 pub offset: u64,
344 pub stream: StreamKind,
345 pub output: String,
346}
347
348/// One `gap` SSE event: the byte range a lagging or late subscriber lost.
349#[derive(Debug, Deserialize, JsonSchema, Serialize)]
350pub struct GapEvent {
351 pub from: u64,
352 pub to: u64,
353}
354
355/// The terminal `exit` SSE event. Emitted before the stream ends, so a client
356/// that sees the body close without one knows the connection failed rather than
357/// the command finishing.
358#[derive(Debug, Deserialize, JsonSchema, Serialize)]
359pub struct ExitEvent {
360 pub exit_code: Option<i32>,
361 pub signal: Option<i32>,
362 /// The daemon execution deadline fired. Absent on older daemons, which cannot
363 /// distinguish deadline expiry from other signal deaths.
364 #[serde(default)]
365 pub timed_out: bool,
366 pub truncated: bool,
367 pub writers_may_be_alive: bool,
368 /// Total bytes published, so a client can assert it saw all of them.
369 pub offset: u64,
370}
371
372#[derive(Debug, Deserialize, JsonSchema, Serialize)]
373pub struct StartResponse {
374 pub exec_id: String,
375 pub phase: Phase,
376}
377
378/// `POST /v1/exec/{id}/kill` response.
379///
380/// A named type rather than the `serde_json::json!` literal this used to be: an
381/// ad-hoc `Value` has no schema to derive, so the one route whose body a client
382/// most needs to branch on — `killed` distinguishes "signalled" from "the group
383/// was already gone", and both are 200 — would have been the one route the
384/// published document could not describe.
385#[derive(Debug, Deserialize, JsonSchema, Serialize)]
386pub struct KillResponse {
387 pub exec_id: String,
388 /// Whether a signal was actually delivered. `false` with a 200 means the
389 /// process group had already exited, which is the outcome a kill wanted.
390 pub killed: bool,
391}
392
393/// One entry of `GET /v1/procs`: an exec's process group as the daemon sees it.
394///
395/// The exec registry knows the group (`pgid`) and whether the direct child has
396/// exited; `/proc` knows which pids are still in that group. Both halves are here
397/// because the interesting case is their disagreement — `child_exited: true` with a
398/// non-empty `pids` is a command that finished while something it started did not,
399/// which is exactly what a caller asking "did this step leave anything behind"
400/// needs to see and could not before.
401#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
402pub struct ProcGroup {
403 pub exec_id: String,
404 /// The process group id captured at spawn. `None` when the child was reaped
405 /// before it could be read, in which case `pids` is empty because there is no
406 /// group to scan for.
407 pub pgid: Option<u32>,
408 /// Seconds since the epoch on the daemon's clock when the child was spawned.
409 /// The same convention as a hook observation's `fired_at`.
410 pub started_at: u64,
411 /// Whether the exec's own child has exited. Read from the terminal marker an
412 /// ack cannot take, so an acked exec still reads as exited here.
413 pub child_exited: bool,
414 /// Whether the exec was started with `reap_group_on_exit`.
415 pub reap: bool,
416 /// Live pids whose process group is `pgid`, read from `/proc`. Zombies are not
417 /// live and are not listed. Empty once the group is gone, and empty for an
418 /// entry whose `pgid` is `None`.
419 pub pids: Vec<u32>,
420}
421
422/// `GET /v1/procs` response.
423#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
424pub struct ProcsResponse {
425 /// Every registered exec, in registry order, whether or not anything in its
426 /// group is still alive. An exec that exited cleanly with nothing left behind
427 /// is listed with `child_exited: true` and `pids: []`.
428 pub procs: Vec<ProcGroup>,
429}
430
431#[derive(Debug, Deserialize, JsonSchema, Serialize)]
432pub struct PollResponse {
433 pub exec_id: String,
434 pub phase: Phase,
435 #[serde(skip_serializing_if = "Option::is_none")]
436 #[serde(flatten)]
437 pub result: Option<Outcome>,
438}
439
440/// The JSON error body every failing control route returns.
441///
442/// `error` is a stable machine-readable slug and `detail` is prose for a human
443/// reading a log. A client branches on `error` and the status code, never on
444/// `detail` — which is why the slug is one of the constants below, chosen at each
445/// call site, rather than a formatted string. `Cow` so the daemon keeps naming a
446/// `&'static str` while a client deserializes into an owned one.
447#[derive(Debug, Deserialize, JsonSchema, Serialize)]
448pub struct ErrorBody {
449 pub error: Cow<'static, str>,
450 pub detail: String,
451}
452
453/// The `event:` names on `GET /v1/exec/{id}/stream`.
454///
455/// Named rather than spelled at each `Event::default().event(..)` call: the daemon
456/// writes these and a client matches on them, and a typo on either side is a
457/// stream that silently carries events nobody dispatches.
458pub const EVENT_OUTPUT: &str = "output";
459pub const EVENT_GAP: &str = "gap";
460pub const EVENT_EXIT: &str = "exit";
461
462// The `ErrorBody::error` slugs. Every failing control route returns one of these,
463// they are the part of the surface a client branches on, and they are `&'static
464// str` for the same reason the status code is a number: the pairing of the two is
465// the contract, and neither is derivable from the other.
466
467/// The body is not a valid request for this route. Always 400, never 404.
468pub const ERROR_MALFORMED_REQUEST: &str = "malformed_request";
469/// No exec is registered under this id, or its entry was collected after an ack.
470pub const ERROR_UNKNOWN_EXEC: &str = "unknown_exec";
471/// The child could not be spawned. 500, deliberately not 404.
472pub const ERROR_SPAWN_FAILED: &str = "spawn_failed";
473/// The requested shutdown is unavailable with the daemon's current privileges.
474pub const ERROR_POWEROFF_UNAVAILABLE: &str = "poweroff_unavailable";
475/// The exec has not exited, so acking would drop output still being written.
476pub const ERROR_STILL_RUNNING: &str = "still_running";
477/// An earlier ack already released the output.
478pub const ERROR_ALREADY_ACKED: &str = "already_acked";
479/// The exec was started without `stdin: true`. Fixable at start time, hence 409.
480pub const ERROR_STDIN_NOT_REQUESTED: &str = "stdin_not_requested";
481/// stdin was closed by an earlier eof, or the child stopped reading. 410: a retry
482/// will never succeed.
483pub const ERROR_STDIN_CLOSED: &str = "stdin_closed";
484/// The child did not read within the configured window. Retryable, and some bytes
485/// may already have been written.
486pub const ERROR_STDIN_WRITE_TIMEOUT: &str = "stdin_write_timeout";
487/// The decoded write exceeds the configured per-write cap.
488pub const ERROR_STDIN_WRITE_TOO_LARGE: &str = "stdin_write_too_large";
489/// The write to the pipe failed for a reason other than a broken pipe.
490pub const ERROR_STDIN_WRITE_FAILED: &str = "stdin_write_failed";
491/// A start request named a user the guest's `/etc/passwd` does not contain. 400, and
492/// nothing was spawned; `detail` names the user.
493pub const ERROR_UNKNOWN_USER: &str = "unknown_user";
494/// A start request named a group the guest's `/etc/group` does not contain. 400, and
495/// nothing was spawned; `detail` names the group.
496pub const ERROR_UNKNOWN_GROUP: &str = "unknown_group";
497/// A start request named a shell that no searched directory holds as an executable
498/// file. 400, and nothing was spawned; `detail` names the shell and where it looked.
499pub const ERROR_UNKNOWN_SHELL: &str = "unknown_shell";
500
501fn is_false(value: &bool) -> bool {
502 !value
503}
504
505impl StartRequest {
506 /// A request to run `command` as an argv under the idempotency key `exec_id`, with
507 /// every other field at the default a body that omits it gets.
508 ///
509 /// `exec_id` has no default here: minting one needs a clock and a random source,
510 /// which this crate doesn't have, and a shared fixed key would answer a second exec
511 /// from the first one's record.
512 ///
513 /// This and the `with_*` setters are the only way to build a request outside this
514 /// crate, because `StartRequest` is `#[non_exhaustive]`. The builder is where every
515 /// optional field gets its one default, the same one serde gives a body that omits it,
516 /// so a client can't pick its own `timeout_sec`, `reap_group_on_exit` or
517 /// `inherit_image_env` by writing the struct out, and a field added here reaches every
518 /// caller as its default instead of as a build error in each client. (The struct's own
519 /// doc comment is the published schema's description, so this lives here.)
520 ///
521 /// ```
522 /// # use microvms_protocol::exec::StartRequest;
523 /// let request: StartRequest = StartRequest::new("e1", vec!["true".into()]);
524 /// # assert!(!request.reap_group_on_exit);
525 /// ```
526 ///
527 /// The same request as a struct expression doesn't build outside this crate (E0639):
528 ///
529 /// ```compile_fail,E0639
530 /// # use microvms_protocol::exec::StartRequest;
531 /// let request: StartRequest = StartRequest { ..StartRequest::new("e1", vec!["true".into()]) };
532 /// # assert!(!request.reap_group_on_exit);
533 /// ```
534 pub fn new(exec_id: impl Into<String>, command: Vec<String>) -> Self {
535 StartRequest {
536 exec_id: exec_id.into(),
537 command,
538 shell: Shell::default(),
539 cwd: None,
540 env: HashMap::new(),
541 user: None,
542 group: None,
543 timeout_sec: None,
544 stdin: false,
545 reap_group_on_exit: false,
546 poweroff_on_exit: false,
547 inherit_image_env: false,
548 }
549 }
550
551 /// Run `command` as a script under this shell, or as an argv for `false`.
552 pub fn with_shell(mut self, shell: impl Into<Shell>) -> Self {
553 self.shell = shell.into();
554 self
555 }
556
557 /// The child's working directory; `None` inherits the daemon's.
558 pub fn with_cwd(mut self, cwd: Option<String>) -> Self {
559 self.cwd = cwd;
560 self
561 }
562
563 /// The top layer of the child's environment.
564 pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
565 self.env = env;
566 self
567 }
568
569 /// The user to demote to; `None` runs as the image's `AGENTD_DEFAULT_USER`, or as the
570 /// daemon's own user when the image sets none.
571 pub fn with_user(mut self, user: Option<NameOrId>) -> Self {
572 self.user = user;
573 self
574 }
575
576 /// The group to demote to; `None` follows [`StartRequest::user`]'s rule.
577 pub fn with_group(mut self, group: Option<NameOrId>) -> Self {
578 self.group = group;
579 self
580 }
581
582 /// The daemon's wall-clock budget for the child; `None` sets none.
583 pub fn with_timeout_sec(mut self, timeout_sec: Option<f64>) -> Self {
584 self.timeout_sec = timeout_sec;
585 self
586 }
587
588 /// Give the child a writable stdin pipe.
589 pub fn with_stdin(mut self, stdin: bool) -> Self {
590 self.stdin = stdin;
591 self
592 }
593
594 /// Signal the exec's process group once its own child exits.
595 pub fn with_reap_group_on_exit(mut self, reap: bool) -> Self {
596 self.reap_group_on_exit = reap;
597 self
598 }
599
600 /// Power off the guest when this exec's original process group has exited.
601 pub fn with_poweroff_on_exit(mut self, poweroff: bool) -> Self {
602 self.poweroff_on_exit = poweroff;
603 self
604 }
605
606 /// Start the child's environment from the image's `ENV`.
607 pub fn with_inherit_image_env(mut self, inherit: bool) -> Self {
608 self.inherit_image_env = inherit;
609 self
610 }
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616
617 /// The `rename_all` is the reason these types are shared rather than mirrored:
618 /// a client that spelled the variants from the Rust identifiers would send
619 /// `Running` where the daemon emits `running`.
620 #[test]
621 fn phase_and_stream_kind_round_trip_through_their_serde_names() {
622 assert_eq!(
623 serde_json::to_string(&Phase::Running).expect("serializes"),
624 "\"running\""
625 );
626 assert_eq!(
627 serde_json::from_str::<Phase>("\"acked\"").expect("deserializes"),
628 Phase::Acked
629 );
630 assert_eq!(
631 serde_json::to_string(&StreamKind::Stderr).expect("serializes"),
632 "\"stderr\""
633 );
634 }
635
636 /// `as_str` is a second spelling of what serde already writes, so the two must be
637 /// equal for every variant or the bindings publish names the wire never carries.
638 ///
639 /// `ALL` is held complete by exhaustion rather than by a length check: the matches
640 /// below have no wildcard arm, so a variant added to either enum fails to compile
641 /// here until it is added to its `ALL` too.
642 #[test]
643 fn as_str_agrees_with_serde_for_every_phase_and_stream_kind() {
644 for phase in Phase::ALL {
645 assert_eq!(
646 serde_json::to_string(&phase).expect("serializes"),
647 format!("\"{}\"", phase.as_str()),
648 "{phase:?} spells differently through as_str and serde"
649 );
650 match phase {
651 Phase::Running | Phase::Exited | Phase::Acked => {}
652 }
653 }
654 for kind in StreamKind::ALL {
655 assert_eq!(
656 serde_json::to_string(&kind).expect("serializes"),
657 format!("\"{}\"", kind.as_str()),
658 "{kind:?} spells differently through as_str and serde"
659 );
660 match kind {
661 StreamKind::Stdout | StreamKind::Stderr => {}
662 }
663 }
664 }
665
666 /// Both halves of serde on one type is the whole point of the crate: what the
667 /// daemon writes is what a client reads, checked rather than assumed.
668 #[test]
669 fn a_response_the_daemon_writes_deserializes_on_the_client_side() {
670 let written = serde_json::to_string(&StartResponse {
671 exec_id: "e1".into(),
672 phase: Phase::Running,
673 })
674 .expect("serializes");
675 let read: StartResponse = serde_json::from_str(&written).expect("deserializes");
676 assert_eq!(read.exec_id, "e1");
677 assert_eq!(read.phase, Phase::Running);
678 }
679
680 /// A request the client writes is one the daemon's extractor accepts, including
681 /// every serde default — the fields a caller omits are the ones most likely to
682 /// disagree across a hand-written mirror.
683 #[test]
684 fn a_start_request_omitting_every_defaulted_field_deserializes() {
685 let request: StartRequest =
686 serde_json::from_str(r#"{"exec_id":"e1","command":["true"]}"#).expect("deserializes");
687 assert_eq!(request.shell, Shell::Flag(false));
688 assert!(!request.shell.is_shell());
689 assert!(request.user.is_none() && request.group.is_none());
690 assert!(
691 !request.inherit_image_env,
692 "inheriting the image env is opt-in: an old client keeps the exact launch map"
693 );
694 assert!(request.cwd.is_none());
695 assert!(request.env.is_empty());
696 assert!(request.timeout_sec.is_none());
697 assert!(!request.stdin);
698 assert!(
699 !request.reap_group_on_exit,
700 "reaping is opt-in: an old client that never heard of the flag keeps today's \
701 grandchild-output guarantee"
702 );
703 }
704
705 /// The builder's defaults are the wire's: a request built with nothing set writes the
706 /// same body the daemon reads from one that omits every optional field. A builder that
707 /// picked its own default would send a different request than an old client does.
708 ///
709 /// **Falsification**: 2026-09-27. Defaulting `reap_group_on_exit` to `true` in
710 /// `StartRequest::new` turned this red; restored after.
711 #[test]
712 fn the_builder_defaults_are_the_ones_serde_gives_an_omitted_field() {
713 let built = StartRequest::new("e1", vec!["true".into()]);
714 let read: StartRequest =
715 serde_json::from_str(r#"{"exec_id":"e1","command":["true"]}"#).expect("deserializes");
716 assert_eq!(
717 serde_json::to_value(&built).expect("serializes"),
718 serde_json::to_value(&read).expect("serializes"),
719 );
720 }
721
722 /// The union fields read both spellings and write each back as it came, so a client
723 /// that only ever sends integers and booleans sends the bytes it always did.
724 #[test]
725 fn user_group_and_shell_read_both_spellings_and_write_them_back() {
726 let request: StartRequest = serde_json::from_str(
727 r#"{"exec_id":"e1","command":["id"],"user":1000,"group":"staff","shell":"bash","inherit_image_env":true}"#,
728 )
729 .expect("deserializes");
730 assert_eq!(request.user, Some(NameOrId::Id(1000)));
731 assert_eq!(request.group, Some(NameOrId::Name("staff".into())));
732 assert_eq!(request.shell, Shell::Named("bash".into()));
733 assert!(request.inherit_image_env);
734
735 let written = serde_json::to_value(&request).expect("serializes");
736 assert_eq!(written["user"], 1000);
737 assert_eq!(written["group"], "staff");
738 assert_eq!(written["shell"], "bash");
739
740 let legacy: StartRequest =
741 serde_json::from_str(r#"{"exec_id":"e1","command":["id"],"shell":true,"user":0}"#)
742 .expect("deserializes");
743 assert_eq!(legacy.shell, Shell::Flag(true));
744 let written = serde_json::to_value(&legacy).expect("serializes");
745 assert_eq!(written["shell"], true);
746 assert_eq!(written["user"], 0);
747 }
748
749 /// Anything but an integer or a string for a user, or a boolean or a string for a
750 /// shell, is a malformed body rather than a coerced value.
751 #[test]
752 fn other_json_types_for_the_union_fields_are_refused() {
753 for body in [
754 r#"{"exec_id":"e1","command":["id"],"user":-1}"#,
755 r#"{"exec_id":"e1","command":["id"],"user":1.5}"#,
756 r#"{"exec_id":"e1","command":["id"],"user":["root"]}"#,
757 r#"{"exec_id":"e1","command":["id"],"group":{"name":"x"}}"#,
758 r#"{"exec_id":"e1","command":["id"],"shell":1}"#,
759 r#"{"exec_id":"e1","command":["id"],"shell":null}"#,
760 ] {
761 assert!(
762 serde_json::from_str::<StartRequest>(body).is_err(),
763 "accepted {body}"
764 );
765 }
766 }
767
768 /// The command-line spelling: digits are an id and go out as an integer.
769 #[test]
770 fn a_flag_of_digits_is_an_id_and_anything_else_a_name() {
771 assert_eq!("1000".parse::<NameOrId>(), Ok(NameOrId::Id(1000)));
772 assert_eq!("0".parse::<NameOrId>(), Ok(NameOrId::Id(0)));
773 assert_eq!(
774 "agent".parse::<NameOrId>(),
775 Ok(NameOrId::Name("agent".into()))
776 );
777 assert_eq!("+1".parse::<NameOrId>(), Ok(NameOrId::Name("+1".into())));
778 assert_eq!(
779 "99999999999".parse::<NameOrId>(),
780 Ok(NameOrId::Name("99999999999".into()))
781 );
782 }
783
784 /// The procs body round-trips with every field present, `pgid: null` included, so a
785 /// client can tell "no group was ever captured" from "the group is empty".
786 #[test]
787 fn a_procs_response_round_trips_with_a_missing_pgid() {
788 let written = serde_json::to_string(&ProcsResponse {
789 procs: vec![ProcGroup {
790 exec_id: "e1".into(),
791 pgid: None,
792 started_at: 1_756_500_000,
793 child_exited: true,
794 reap: false,
795 pids: Vec::new(),
796 }],
797 })
798 .expect("serializes");
799 assert_eq!(
800 written,
801 r#"{"procs":[{"exec_id":"e1","pgid":null,"started_at":1756500000,"child_exited":true,"reap":false,"pids":[]}]}"#
802 );
803 let read: ProcsResponse = serde_json::from_str(&written).expect("deserializes");
804 assert_eq!(read.procs.len(), 1);
805 assert_eq!(read.procs[0].pgid, None);
806 assert!(read.procs[0].child_exited);
807 }
808
809 /// The flatten-plus-skip on `PollResponse.result` is the one shape whose two
810 /// serde contracts genuinely differ — absent on the way out while running, and
811 /// the same absence has to read back as `None` on the way in. Both directions,
812 /// because a client that read a running exec's missing outcome as an error would
813 /// fail on every poll before the first one that mattered.
814 #[test]
815 fn a_poll_response_round_trips_with_and_without_an_outcome() {
816 let running = serde_json::to_string(&PollResponse {
817 exec_id: "e1".into(),
818 phase: Phase::Running,
819 result: None,
820 })
821 .expect("serializes");
822 assert_eq!(running, r#"{"exec_id":"e1","phase":"running"}"#);
823 let read: PollResponse = serde_json::from_str(&running).expect("deserializes");
824 assert!(read.result.is_none());
825
826 let exited = serde_json::to_string(&PollResponse {
827 exec_id: "e1".into(),
828 phase: Phase::Exited,
829 result: Some(Outcome {
830 exit_code: Some(0),
831 stdout: "hi".into(),
832 ..Outcome::default()
833 }),
834 })
835 .expect("serializes");
836 let read: PollResponse = serde_json::from_str(&exited).expect("deserializes");
837 let outcome = read.result.expect("the outcome is flattened into the body");
838 assert_eq!(outcome.exit_code, Some(0));
839 assert_eq!(outcome.stdout, "hi");
840 }
841
842 /// `error` is a `Cow`, so the daemon's borrowed slug and a client's owned string
843 /// are the same type on the wire.
844 #[test]
845 fn an_error_body_carries_a_borrowed_slug_and_reads_back_owned() {
846 let written = serde_json::to_string(&ErrorBody {
847 error: Cow::Borrowed(ERROR_UNKNOWN_EXEC),
848 detail: "e1".into(),
849 })
850 .expect("serializes");
851 assert_eq!(written, r#"{"error":"unknown_exec","detail":"e1"}"#);
852 let read: ErrorBody = serde_json::from_str(&written).expect("deserializes");
853 assert_eq!(read.error, ERROR_UNKNOWN_EXEC);
854 }
855
856 #[test]
857 fn poweroff_defaults_to_absent_and_true_round_trips() {
858 let request = StartRequest::new("e1", vec!["true".into()]);
859 assert!(!request.poweroff_on_exit);
860 let body = serde_json::to_value(&request).unwrap();
861 assert!(body.get("poweroff_on_exit").is_none());
862 assert!(
863 !serde_json::from_value::<StartRequest>(body)
864 .unwrap()
865 .poweroff_on_exit
866 );
867 let body = serde_json::to_value(request.with_poweroff_on_exit(true)).unwrap();
868 assert_eq!(body["poweroff_on_exit"], true);
869 assert!(
870 serde_json::from_value::<StartRequest>(body)
871 .unwrap()
872 .poweroff_on_exit
873 );
874 }
875}