arcbox_vm_proto/exec.rs
1//! Exec channel: the guest vsock port the agent serves exec/run sessions
2//! and control commands on, plus the readiness dial-out port.
3//!
4//! ## Frame format
5//!
6//! Every message (in both directions) is:
7//!
8//! ```text
9//! [u8: msg_type][u32 LE: payload_len][payload_len bytes: payload]
10//! ```
11//!
12//! | Type | Direction | Payload |
13//! |------|-------------|--------------------------------------------|
14//! | 0x01 | Host→Agent | JSON-encoded [`StartCommand`] |
15//! | 0x02 | Host→Agent | raw stdin bytes |
16//! | 0x03 | Host→Agent | `[u16 LE width][u16 LE height]` |
17//! | 0x04 | Host→Agent | empty — signals stdin EOF |
18//! | 0x05 | Host→Agent | `[i64 LE secs][u32 LE nanos]` — clock sync |
19//! | 0x06 | Host→Agent | JSON `NetReconfigCommand` — re-address eth0 |
20//! | 0x07 | Host→Agent | `[i32 LE signal]` — deliver to workload |
21//! | 0x08 | Host→Agent | JSON [`WaitPortReq`] — wait for a listener |
22//! | 0x10 | Agent→Host | raw stdout bytes |
23//! | 0x11 | Agent→Host | raw stderr bytes |
24//! | 0x12 | Agent→Host | `[i32 LE code][i32 LE signal]` (signal 0 = normal exit; old agents send only the 4-byte code). Net-reconfig replies append six `u32 LE` micros. Readers key on payload length. |
25//!
26//! Agents ignore frame types they do not know, so a new host→agent opcode
27//! is a silent no-op against an agent from before it.
28
29use std::collections::HashMap;
30
31use serde::{Deserialize, Serialize};
32
33/// Host-side port the guest agent dials once it is fully serving.
34///
35/// Firecracker hybrid vsock forwards a guest-initiated connect to host port
36/// `P` onto the host Unix socket at `{uds_path}_{P}`, so a pre-bound
37/// listener's `accept()` IS the "vm-agent is up" event — no connect
38/// polling involved.
39pub const READY_PORT: u32 = 51;
40
41/// Guest-side vsock port the agent listens on (exec channel).
42pub const AGENT_PORT: u32 = 52;
43
44/// Start a session. Payload: JSON [`StartCommand`].
45pub const MSG_START: u8 = 0x01;
46/// Raw stdin bytes for the session's process.
47pub const MSG_STDIN: u8 = 0x02;
48/// Resize the session's pseudo-TTY. Payload: `[u16 LE width][u16 LE height]`.
49pub const MSG_RESIZE: u8 = 0x03;
50/// Signal EOF on the process's stdin. Empty payload.
51pub const MSG_EOF: u8 = 0x04;
52/// Synchronise the guest clock to the host (after snapshot restore, and as
53/// the cold-boot agent-readiness gate).
54/// Payload: `[i64 LE unix_seconds][u32 LE nanos]` (12 bytes).
55pub const MSG_CLOCK_SYNC: u8 = 0x05;
56/// Re-address the guest network after a fresh-network snapshot restore.
57/// Payload: JSON [`NetReconfigCommand`](crate::boot::NetReconfigCommand).
58pub const MSG_NET_RECONFIG: u8 = 0x06;
59/// Deliver a POSIX signal to the workload's process group.
60/// Payload: `[i32 LE signal]` (4 bytes).
61pub const MSG_SIGNAL: u8 = 0x07;
62/// Wait until the guest's TCP listen table has a listener on a port.
63/// Payload: JSON [`WaitPortReq`]; answered with [`MSG_EXIT`] carrying `0`
64/// (listening) or `1` (deadline elapsed).
65pub const MSG_WAIT_PORT: u8 = 0x08;
66
67/// Raw stdout bytes from the session's process (the merged PTY stream for
68/// `tty` sessions).
69pub const MSG_STDOUT: u8 = 0x10;
70/// Raw stderr bytes from the session's process (never sent for `tty`
71/// sessions).
72pub const MSG_STDERR: u8 = 0x11;
73/// The process terminated, or a control command completed.
74/// Payload: `[i32 LE code][i32 LE signal]`; see the module table for the
75/// legacy 4-byte form and the net-reconfig timing suffix.
76pub const MSG_EXIT: u8 = 0x12;
77
78/// Maximum allowed frame payload size (16 MiB) on every channel.
79pub const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024;
80
81/// Parameters forwarded to the guest agent as the session-start frame.
82///
83/// The host always serializes every field; the defaults exist for the
84/// decoder, so an agent reads a start frame from any host generation.
85#[derive(Debug, Serialize, Deserialize)]
86pub struct StartCommand {
87 /// Program and arguments (`argv`), never empty.
88 pub cmd: Vec<String>,
89 /// Environment for the process, replacing the agent's own.
90 #[serde(default)]
91 pub env: HashMap<String, String>,
92 /// Working directory; empty leaves the agent's own cwd in place.
93 #[serde(default)]
94 pub working_dir: String,
95 /// Docker-style user spec (`uid`, `uid:gid`, `name`, `name:group`)
96 /// resolved against the sandbox rootfs; empty runs as the agent's own
97 /// user (root inside the microVM).
98 #[serde(default)]
99 pub user: String,
100 /// Allocate a pseudo-TTY and merge stdout/stderr into it.
101 #[serde(default)]
102 pub tty: bool,
103 /// Initial PTY width in columns.
104 #[serde(default = "default_tty_width")]
105 pub tty_width: u16,
106 /// Initial PTY height in rows.
107 #[serde(default = "default_tty_height")]
108 pub tty_height: u16,
109 /// Kill the process after this many seconds; `0` means no timeout.
110 #[serde(default)]
111 pub timeout_seconds: u32,
112}
113
114const fn default_tty_width() -> u16 {
115 80
116}
117
118const fn default_tty_height() -> u16 {
119 24
120}
121
122/// [`MSG_WAIT_PORT`] payload.
123#[derive(Debug, Serialize, Deserialize)]
124pub struct WaitPortReq {
125 /// TCP port a workload is expected to listen on.
126 pub port: u16,
127 /// Give up after this long (0 = check once and answer immediately).
128 pub timeout_ms: u64,
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn start_command_decodes_with_only_cmd() {
137 // Old hosts (inside restored snapshots the roles invert: a new host
138 // talks to an old agent, but the decoder tolerance is the same
139 // contract) may omit every field but `cmd`.
140 let cmd: StartCommand = serde_json::from_str(r#"{"cmd":["sh"]}"#).unwrap();
141 assert_eq!(cmd.cmd, ["sh"]);
142 assert!(cmd.env.is_empty());
143 assert_eq!(cmd.working_dir, "");
144 assert_eq!(cmd.user, "");
145 assert!(!cmd.tty);
146 assert_eq!((cmd.tty_width, cmd.tty_height), (80, 24));
147 assert_eq!(cmd.timeout_seconds, 0);
148 }
149
150 #[test]
151 fn start_command_round_trips_every_field() {
152 let cmd = StartCommand {
153 cmd: vec!["env".into()],
154 env: HashMap::from([("K".to_owned(), "v".to_owned())]),
155 working_dir: "/w".into(),
156 user: "1000:1000".into(),
157 tty: true,
158 tty_width: 120,
159 tty_height: 40,
160 timeout_seconds: 7,
161 };
162 let json = serde_json::to_string(&cmd).unwrap();
163 let back: StartCommand = serde_json::from_str(&json).unwrap();
164 assert_eq!(back.cmd, cmd.cmd);
165 assert_eq!(back.env, cmd.env);
166 assert_eq!(back.working_dir, cmd.working_dir);
167 assert_eq!(back.user, cmd.user);
168 assert_eq!(back.tty, cmd.tty);
169 assert_eq!((back.tty_width, back.tty_height), (120, 40));
170 assert_eq!(back.timeout_seconds, 7);
171 }
172}