Skip to main content

microsandbox_protocol/
message.rs

1//! Message envelope and type definitions for the agent protocol.
2
3use serde::{Deserialize, Serialize, de::DeserializeOwned};
4
5use crate::error::ProtocolResult;
6
7//--------------------------------------------------------------------------------------------------
8// Constants
9//--------------------------------------------------------------------------------------------------
10
11/// Current protocol version.
12pub const PROTOCOL_VERSION: u8 = 10;
13
14/// Frame flag: this is the last message for the given correlation ID.
15///
16/// Set on terminal message types such as `ExecExited`, `FsResponse`, and `TcpClosed`.
17pub const FLAG_TERMINAL: u8 = 0b0000_0001;
18
19/// Frame flag: this is the first message of a new session.
20///
21/// Set on session-initiating message types such as `ExecRequest`, `FsRequest`, and `TcpConnect`.
22pub const FLAG_SESSION_START: u8 = 0b0000_0010;
23
24/// Frame flag: this message requests sandbox shutdown.
25///
26/// Set on `Shutdown` messages. The sandbox-process relay uses this to trigger
27/// drain escalation (SIGTERM → SIGKILL) if the guest doesn't exit voluntarily.
28pub const FLAG_SHUTDOWN: u8 = 0b0000_0100;
29
30/// Frame flag: the body is a generation-8 raw bulk record rather than CBOR.
31pub const FLAG_BULK: u8 = 0b0000_1000;
32
33/// Size of the frame header fields that sit between the length prefix and the
34/// control or raw body: `[id: u32 BE][flags: u8]` = 5 bytes.
35pub const FRAME_HEADER_SIZE: usize = 5;
36
37//--------------------------------------------------------------------------------------------------
38// Types
39//--------------------------------------------------------------------------------------------------
40
41/// The message envelope sent over the wire.
42///
43/// Each message contains a version, type, correlation ID, flags, and a CBOR payload.
44///
45/// Wire format: `[len: u32 BE][id: u32 BE][flags: u8][CBOR(v, t, p)]`
46///
47/// The `id` and `flags` fields live in the binary frame header (outside CBOR)
48/// so that relay intermediaries can route frames without CBOR parsing.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct Message {
51    /// Protocol generation, echoed into the frame.
52    ///
53    /// This is the single protocol version axis (see `VERSIONING.md`), the same
54    /// number negotiated once at the handshake — not a second, message-local
55    /// version. It is carried here so a frame is self-describing for debugging
56    /// and telemetry; behavior is gated on the negotiated generation, not on
57    /// reading this field per message.
58    pub v: u8,
59
60    /// Message type.
61    pub t: MessageType,
62
63    /// Correlation ID used to associate requests with responses and
64    /// to identify exec sessions.
65    ///
66    /// Serialized in the binary frame header, not in CBOR.
67    #[serde(skip)]
68    pub id: u32,
69
70    /// Frame flags computed from the message type.
71    ///
72    /// Serialized in the binary frame header, not in CBOR.
73    #[serde(skip)]
74    pub flags: u8,
75
76    /// The CBOR-encoded payload bytes.
77    #[serde(with = "serde_bytes")]
78    pub p: Vec<u8>,
79}
80
81/// Identifies the type of a protocol message.
82///
83/// The `#[strum(serialize = ...)]` attribute on each variant is the single
84/// source for its wire string: [`as_str`](Self::as_str) and
85/// [`from_wire_str`](Self::from_wire_str) are derived from it, and
86/// [`strum::IntoEnumIterator`] yields every variant for exhaustive iteration
87/// (the schema snapshot) without a hand-maintained list.
88#[derive(
89    Debug,
90    Clone,
91    Copy,
92    PartialEq,
93    Eq,
94    Hash,
95    strum::IntoStaticStr,
96    strum::AsRefStr,
97    strum::EnumString,
98    strum::EnumIter,
99)]
100pub enum MessageType {
101    /// Guest agent is ready.
102    #[strum(serialize = "core.ready")]
103    Ready,
104
105    /// Guest reports init context before user mounts.
106    #[strum(serialize = "core.init.resolved")]
107    InitResolved,
108
109    /// Host acknowledges init-context setup.
110    #[strum(serialize = "core.init.ack")]
111    InitAck,
112
113    /// Host requests shutdown.
114    #[strum(serialize = "core.shutdown")]
115    Shutdown,
116
117    /// Host relay reports that one SDK client disconnected.
118    #[strum(serialize = "core.relay.client.disconnected")]
119    RelayClientDisconnected,
120
121    /// Host asks the guest to synchronize `CLOCK_REALTIME`.
122    #[strum(serialize = "core.clock.sync")]
123    ClockSync,
124
125    /// Host checks whether the guest agent is reachable.
126    #[strum(serialize = "core.ping")]
127    Ping,
128
129    /// Guest confirms that the guest agent is reachable.
130    #[strum(serialize = "core.pong")]
131    Pong,
132
133    /// Host explicitly records sandbox activity.
134    #[strum(serialize = "core.touch")]
135    Touch,
136
137    /// Guest confirms that sandbox activity was recorded.
138    #[strum(serialize = "core.touched")]
139    Touched,
140
141    /// Host asks agentd to freeze all agent-managed workload processes.
142    #[strum(serialize = "core.workload.freeze")]
143    WorkloadFreeze,
144
145    /// Guest confirms that every agent-managed workload process is frozen.
146    #[strum(serialize = "core.workload.frozen")]
147    WorkloadFrozen,
148
149    /// Host asks agentd to release a previously established workload freeze.
150    #[strum(serialize = "core.workload.thaw")]
151    WorkloadThaw,
152
153    /// Guest confirms that agent-managed workload processes may run again.
154    #[strum(serialize = "core.workload.thawed")]
155    WorkloadThawed,
156
157    /// Guest grants cumulative ordinary input capacity to its host relay.
158    #[strum(serialize = "core.workload.transport.credit")]
159    WorkloadTransportCredit,
160
161    /// Host checks mounted root-filesystem growth before changing block capacity.
162    #[strum(serialize = "core.root_disk.prepare")]
163    RootDiskPrepare,
164
165    /// Host requests mounted root-filesystem expansion after block capacity changed.
166    #[strum(serialize = "core.root_disk.grow")]
167    RootDiskGrow,
168
169    /// Guest reports the root filesystem and device capacities.
170    #[strum(serialize = "core.root_disk.state")]
171    RootDiskState,
172
173    /// Peer reports a recoverable protocol-level error.
174    #[strum(serialize = "core.error")]
175    CoreError,
176
177    /// Guest accepts the raw-bulk offer on an opening operation.
178    #[strum(serialize = "core.bulk.accepted")]
179    BulkAccepted,
180
181    /// Receiver grants an absolute send limit for one bulk flow.
182    #[strum(serialize = "core.bulk.credit")]
183    BulkCredit,
184
185    /// Sender declares the exact final offset of one bulk flow.
186    #[strum(serialize = "core.bulk.finish")]
187    BulkFinish,
188
189    /// Peer asks to stop an entire bulk correlation.
190    #[strum(serialize = "core.bulk.cancel")]
191    BulkCancel,
192
193    /// Host requests command execution.
194    #[strum(serialize = "core.exec.request")]
195    ExecRequest,
196
197    /// Guest confirms command started.
198    #[strum(serialize = "core.exec.started")]
199    ExecStarted,
200
201    /// Host sends stdin data.
202    #[strum(serialize = "core.exec.stdin")]
203    ExecStdin,
204
205    /// Guest reports that a prior `ExecStdin` write to the child's
206    /// stdin failed (e.g. the child closed its read end). Non-terminal:
207    /// the session continues and may still produce stdout/stderr and
208    /// an exit code.
209    #[strum(serialize = "core.exec.stdin.error")]
210    ExecStdinError,
211
212    /// Guest sends stdout data.
213    #[strum(serialize = "core.exec.stdout")]
214    ExecStdout,
215
216    /// Guest sends stderr data.
217    #[strum(serialize = "core.exec.stderr")]
218    ExecStderr,
219
220    /// Guest reports command exit.
221    #[strum(serialize = "core.exec.exited")]
222    ExecExited,
223
224    /// Guest reports command failed to spawn (binary not found,
225    /// permission denied, etc.). Distinct from `ExecExited` —
226    /// `ExecFailed` means the user code never ran. Terminal.
227    #[strum(serialize = "core.exec.failed")]
228    ExecFailed,
229
230    /// Host requests PTY resize.
231    #[strum(serialize = "core.exec.resize")]
232    ExecResize,
233
234    /// Host sends signal to process.
235    #[strum(serialize = "core.exec.signal")]
236    ExecSignal,
237
238    /// Return consumed stdout/stderr window credit for a flow-controlled execution.
239    #[strum(serialize = "core.exec.output.ack")]
240    ExecOutputAck,
241
242    /// Return stdin capacity after the child consumes piped input.
243    #[strum(serialize = "core.exec.stdin.ack")]
244    ExecStdinAck,
245
246    /// Host requests a filesystem operation.
247    #[strum(serialize = "core.fs.request")]
248    FsRequest,
249
250    /// Guest sends a terminal filesystem response.
251    #[strum(serialize = "core.fs.response")]
252    FsResponse,
253
254    /// Streaming file data chunk (bidirectional).
255    #[strum(serialize = "core.fs.data")]
256    FsData,
257
258    /// Host requests a TCP connection from inside the guest.
259    #[strum(serialize = "core.tcp.connect")]
260    TcpConnect,
261
262    /// Guest confirms that a TCP connection was opened.
263    #[strum(serialize = "core.tcp.connected")]
264    TcpConnected,
265
266    /// TCP stream data chunk (bidirectional).
267    #[strum(serialize = "core.tcp.data")]
268    TcpData,
269
270    /// One TCP stream side has closed its write half.
271    #[strum(serialize = "core.tcp.eof")]
272    TcpEof,
273
274    /// Host requests a TCP session close.
275    #[strum(serialize = "core.tcp.close")]
276    TcpClose,
277
278    /// Guest reports that a TCP session is closed. Terminal.
279    #[strum(serialize = "core.tcp.closed")]
280    TcpClosed,
281
282    /// Guest reports that a TCP session failed. Terminal.
283    #[strum(serialize = "core.tcp.failed")]
284    TcpFailed,
285
286    /// Host supplies one-shot guest bootstrap configuration.
287    #[strum(serialize = "core.bootstrap")]
288    Bootstrap,
289}
290
291//--------------------------------------------------------------------------------------------------
292// Methods
293//--------------------------------------------------------------------------------------------------
294
295impl Message {
296    /// Creates a new message with the current protocol version and raw payload bytes.
297    pub fn new(t: MessageType, id: u32, p: Vec<u8>) -> Self {
298        let flags = t.flags();
299        Self {
300            v: PROTOCOL_VERSION,
301            t,
302            id,
303            flags,
304            p,
305        }
306    }
307
308    /// Creates a new message by serializing the given payload to CBOR.
309    pub fn with_payload<T: Serialize>(
310        t: MessageType,
311        id: u32,
312        payload: &T,
313    ) -> ProtocolResult<Self> {
314        let mut p = Vec::new();
315        ciborium::into_writer(payload, &mut p)?;
316        let flags = t.flags();
317        Ok(Self {
318            v: PROTOCOL_VERSION,
319            t,
320            id,
321            flags,
322            p,
323        })
324    }
325
326    /// Deserializes the payload bytes into the given type.
327    pub fn payload<T: DeserializeOwned>(&self) -> ProtocolResult<T> {
328        Ok(ciborium::from_reader(&self.p[..])?)
329    }
330}
331
332impl MessageType {
333    /// Whether this execution message belongs to the bounded progress-control class.
334    pub fn is_exec_control(self) -> bool {
335        matches!(
336            self,
337            Self::ExecOutputAck | Self::ExecStdinAck | Self::ExecSignal | Self::ExecResize
338        )
339    }
340
341    /// Whether host-to-guest delivery can retain payload credit behind a workload consumer.
342    ///
343    /// The bundled workload barrier uses logical classes, not physical console ports. Payload
344    /// messages (including ordered EOF) share data credit with raw bulk records, leaving control
345    /// capacity available for fresh commands when restored stdin has not yet been consumed.
346    pub fn uses_workload_data_credit(self) -> bool {
347        matches!(
348            self,
349            Self::ExecStdin | Self::FsData | Self::TcpData | Self::TcpEof
350        )
351    }
352
353    /// Computes the frame flags byte for this message type.
354    pub fn flags(&self) -> u8 {
355        match self {
356            Self::Pong
357            | Self::Touched
358            | Self::WorkloadFrozen
359            | Self::WorkloadThawed
360            | Self::RootDiskState
361            | Self::CoreError
362            | Self::ExecExited
363            | Self::ExecFailed
364            | Self::FsResponse
365            | Self::TcpClosed
366            | Self::TcpFailed => FLAG_TERMINAL,
367            Self::ExecRequest | Self::FsRequest | Self::TcpConnect => FLAG_SESSION_START,
368            Self::Shutdown => FLAG_SHUTDOWN,
369            _ => 0,
370        }
371    }
372
373    /// The protocol generation that introduced this message type.
374    ///
375    /// A per-type label on the single protocol generation axis (see
376    /// `VERSIONING.md`), not a separate version counter. The send path gates on
377    /// it: a type whose generation exceeds the peer's negotiated generation is
378    /// rejected locally with a typed error instead of being sent to a peer that
379    /// cannot handle it, so only that one feature fails rather than the session.
380    ///
381    /// Core and exec types belong to the generation-1 baseline; they work on
382    /// every runtime we still talk to, including the pre-0.5 legacy one.
383    /// Filesystem streaming did not exist in the pre-0.5 legacy protocol
384    /// (generation 1), so the `Fs*` types require generation 2 or newer.
385    /// TCP forwarding was introduced in generation 4. `core.error` was
386    /// introduced in generation 5. Reachability checks and explicit idle
387    /// refreshes were introduced in generation 6.
388    ///
389    /// There is deliberately no wildcard arm: adding a new `MessageType` must
390    /// force a conscious choice of the generation that introduced it (and a
391    /// matching `PROTOCOL_VERSION` bump). Message types are append-only — never
392    /// lower or re-purpose an existing value.
393    pub fn min_protocol_version(&self) -> u8 {
394        match self {
395            Self::Ready
396            | Self::InitResolved
397            | Self::InitAck
398            | Self::Shutdown
399            | Self::RelayClientDisconnected
400            | Self::ClockSync
401            | Self::ExecRequest
402            | Self::ExecStarted
403            | Self::ExecStdin
404            | Self::ExecStdinError
405            | Self::ExecStdout
406            | Self::ExecStderr
407            | Self::ExecExited
408            | Self::ExecFailed
409            | Self::ExecResize
410            | Self::ExecSignal => 1,
411            Self::ExecOutputAck | Self::ExecStdinAck => 10,
412            Self::FsRequest | Self::FsResponse | Self::FsData => 2,
413            Self::CoreError => 5,
414            Self::Ping | Self::Pong | Self::Touch | Self::Touched => 6,
415            Self::Bootstrap => 7,
416            Self::WorkloadFreeze
417            | Self::WorkloadFrozen
418            | Self::WorkloadThaw
419            | Self::WorkloadThawed
420            | Self::WorkloadTransportCredit => 9,
421            Self::RootDiskPrepare | Self::RootDiskGrow | Self::RootDiskState => 9,
422            Self::BulkAccepted | Self::BulkCredit | Self::BulkFinish | Self::BulkCancel => 8,
423            Self::TcpConnect
424            | Self::TcpConnected
425            | Self::TcpData
426            | Self::TcpEof
427            | Self::TcpClose
428            | Self::TcpClosed
429            | Self::TcpFailed => 4,
430        }
431    }
432
433    /// Whether a peer that speaks `peer_generation` is new enough to handle this
434    /// message type.
435    ///
436    /// The shared version-compatibility primitive for both directions. The host
437    /// gates its sends on it (`AgentClient::ensure_version_compat`); the guest
438    /// can gate a guest-initiated message the same way, reading the peer's
439    /// generation from the `v` field of the request that established the session.
440    /// See `VERSIONING.md`.
441    pub fn is_available_at(&self, peer_generation: u8) -> bool {
442        self.min_protocol_version() <= peer_generation
443    }
444
445    /// Returns the wire string representation.
446    ///
447    /// Backed by the per-variant `#[strum(serialize = ...)]` attribute, the
448    /// single source of truth for wire strings.
449    pub fn as_str(&self) -> &'static str {
450        (*self).into()
451    }
452
453    /// Parses a wire string into a message type, the inverse of
454    /// [`as_str`](Self::as_str). Returns `None` for an unknown string.
455    pub fn from_wire_str(s: &str) -> Option<Self> {
456        s.parse().ok()
457    }
458}
459
460//--------------------------------------------------------------------------------------------------
461// Trait Implementations
462//--------------------------------------------------------------------------------------------------
463
464impl Serialize for MessageType {
465    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
466    where
467        S: serde::Serializer,
468    {
469        serializer.serialize_str(self.as_str())
470    }
471}
472
473impl<'de> Deserialize<'de> for MessageType {
474    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
475    where
476        D: serde::Deserializer<'de>,
477    {
478        let s = String::deserialize(deserializer)?;
479        Self::from_wire_str(&s)
480            .ok_or_else(|| serde::de::Error::custom(format!("unknown message type: {s}")))
481    }
482}
483
484//--------------------------------------------------------------------------------------------------
485// Tests
486//--------------------------------------------------------------------------------------------------
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    #[test]
493    fn retained_payload_and_eof_use_data_credit_without_changing_frame_flags() {
494        for message in [
495            MessageType::ExecStdin,
496            MessageType::FsData,
497            MessageType::TcpData,
498            MessageType::TcpEof,
499        ] {
500            assert!(message.uses_workload_data_credit());
501            assert_eq!(
502                message.flags(),
503                0,
504                "logical admission must not change the wire header"
505            );
506        }
507        for message in [
508            MessageType::ExecRequest,
509            MessageType::Ping,
510            MessageType::FsRequest,
511            MessageType::TcpConnect,
512            MessageType::ExecSignal,
513            MessageType::BulkFinish,
514            MessageType::BulkCancel,
515            MessageType::RelayClientDisconnected,
516        ] {
517            assert!(!message.uses_workload_data_credit());
518        }
519    }
520
521    #[test]
522    fn test_message_type_roundtrip() {
523        let types = [
524            (MessageType::Bootstrap, "core.bootstrap"),
525            (MessageType::Ready, "core.ready"),
526            (MessageType::InitResolved, "core.init.resolved"),
527            (MessageType::InitAck, "core.init.ack"),
528            (MessageType::Shutdown, "core.shutdown"),
529            (
530                MessageType::RelayClientDisconnected,
531                "core.relay.client.disconnected",
532            ),
533            (MessageType::ClockSync, "core.clock.sync"),
534            (MessageType::Ping, "core.ping"),
535            (MessageType::Pong, "core.pong"),
536            (MessageType::Touch, "core.touch"),
537            (MessageType::Touched, "core.touched"),
538            (MessageType::WorkloadFreeze, "core.workload.freeze"),
539            (MessageType::WorkloadFrozen, "core.workload.frozen"),
540            (MessageType::WorkloadThaw, "core.workload.thaw"),
541            (MessageType::WorkloadThawed, "core.workload.thawed"),
542            (
543                MessageType::WorkloadTransportCredit,
544                "core.workload.transport.credit",
545            ),
546            (MessageType::CoreError, "core.error"),
547            (MessageType::BulkAccepted, "core.bulk.accepted"),
548            (MessageType::BulkCredit, "core.bulk.credit"),
549            (MessageType::BulkFinish, "core.bulk.finish"),
550            (MessageType::BulkCancel, "core.bulk.cancel"),
551            (MessageType::ExecRequest, "core.exec.request"),
552            (MessageType::ExecStarted, "core.exec.started"),
553            (MessageType::ExecStdin, "core.exec.stdin"),
554            (MessageType::ExecStdinError, "core.exec.stdin.error"),
555            (MessageType::ExecStdout, "core.exec.stdout"),
556            (MessageType::ExecStderr, "core.exec.stderr"),
557            (MessageType::ExecExited, "core.exec.exited"),
558            (MessageType::ExecFailed, "core.exec.failed"),
559            (MessageType::ExecResize, "core.exec.resize"),
560            (MessageType::ExecSignal, "core.exec.signal"),
561            (MessageType::FsRequest, "core.fs.request"),
562            (MessageType::FsResponse, "core.fs.response"),
563            (MessageType::FsData, "core.fs.data"),
564            (MessageType::TcpConnect, "core.tcp.connect"),
565            (MessageType::TcpConnected, "core.tcp.connected"),
566            (MessageType::TcpData, "core.tcp.data"),
567            (MessageType::TcpEof, "core.tcp.eof"),
568            (MessageType::TcpClose, "core.tcp.close"),
569            (MessageType::TcpClosed, "core.tcp.closed"),
570            (MessageType::TcpFailed, "core.tcp.failed"),
571        ];
572
573        for (mt, expected_str) in &types {
574            assert_eq!(mt.as_str(), *expected_str);
575            assert_eq!(MessageType::from_wire_str(expected_str).unwrap(), *mt);
576        }
577    }
578
579    #[test]
580    fn test_message_type_serde_roundtrip() {
581        let types = [
582            MessageType::Bootstrap,
583            MessageType::Ready,
584            MessageType::InitResolved,
585            MessageType::InitAck,
586            MessageType::Shutdown,
587            MessageType::RelayClientDisconnected,
588            MessageType::ClockSync,
589            MessageType::Ping,
590            MessageType::Pong,
591            MessageType::Touch,
592            MessageType::Touched,
593            MessageType::WorkloadFreeze,
594            MessageType::WorkloadFrozen,
595            MessageType::WorkloadThaw,
596            MessageType::WorkloadThawed,
597            MessageType::WorkloadTransportCredit,
598            MessageType::CoreError,
599            MessageType::BulkAccepted,
600            MessageType::BulkCredit,
601            MessageType::BulkFinish,
602            MessageType::BulkCancel,
603            MessageType::ExecRequest,
604            MessageType::ExecStarted,
605            MessageType::ExecStdin,
606            MessageType::ExecStdinError,
607            MessageType::ExecStdout,
608            MessageType::ExecStderr,
609            MessageType::ExecExited,
610            MessageType::ExecFailed,
611            MessageType::ExecResize,
612            MessageType::ExecSignal,
613            MessageType::FsRequest,
614            MessageType::FsResponse,
615            MessageType::FsData,
616            MessageType::TcpConnect,
617            MessageType::TcpConnected,
618            MessageType::TcpData,
619            MessageType::TcpEof,
620            MessageType::TcpClose,
621            MessageType::TcpClosed,
622            MessageType::TcpFailed,
623        ];
624
625        for mt in &types {
626            let mut buf = Vec::new();
627            ciborium::into_writer(mt, &mut buf).unwrap();
628            let decoded: MessageType = ciborium::from_reader(&buf[..]).unwrap();
629            assert_eq!(&decoded, mt);
630        }
631    }
632
633    #[test]
634    fn test_unknown_message_type() {
635        assert!(MessageType::from_wire_str("core.unknown").is_none());
636    }
637
638    #[test]
639    fn test_message_with_payload_roundtrip() {
640        use crate::exec::ExecExited;
641
642        let msg =
643            Message::with_payload(MessageType::ExecExited, 7, &ExecExited { code: 42 }).unwrap();
644
645        assert_eq!(msg.t, MessageType::ExecExited);
646        assert_eq!(msg.id, 7);
647        assert_eq!(msg.flags, FLAG_TERMINAL);
648
649        let payload: ExecExited = msg.payload().unwrap();
650        assert_eq!(payload.code, 42);
651    }
652
653    #[test]
654    fn test_message_type_flags() {
655        assert_eq!(MessageType::ExecExited.flags(), FLAG_TERMINAL);
656        assert_eq!(MessageType::ExecFailed.flags(), FLAG_TERMINAL);
657        assert_eq!(MessageType::FsResponse.flags(), FLAG_TERMINAL);
658        assert_eq!(MessageType::TcpClosed.flags(), FLAG_TERMINAL);
659        assert_eq!(MessageType::TcpFailed.flags(), FLAG_TERMINAL);
660        assert_eq!(MessageType::Pong.flags(), FLAG_TERMINAL);
661        assert_eq!(MessageType::Touched.flags(), FLAG_TERMINAL);
662        assert_eq!(MessageType::WorkloadFrozen.flags(), FLAG_TERMINAL);
663        assert_eq!(MessageType::WorkloadThawed.flags(), FLAG_TERMINAL);
664        assert_eq!(MessageType::ExecRequest.flags(), FLAG_SESSION_START);
665        assert_eq!(MessageType::FsRequest.flags(), FLAG_SESSION_START);
666        assert_eq!(MessageType::TcpConnect.flags(), FLAG_SESSION_START);
667        assert_eq!(MessageType::Ready.flags(), 0);
668        assert_eq!(MessageType::Bootstrap.flags(), 0);
669        assert_eq!(MessageType::InitResolved.flags(), 0);
670        assert_eq!(MessageType::InitAck.flags(), 0);
671        assert_eq!(MessageType::Shutdown.flags(), FLAG_SHUTDOWN);
672        assert_eq!(MessageType::ClockSync.flags(), 0);
673        assert_eq!(MessageType::Ping.flags(), 0);
674        assert_eq!(MessageType::Touch.flags(), 0);
675        assert_eq!(MessageType::WorkloadFreeze.flags(), 0);
676        assert_eq!(MessageType::WorkloadThaw.flags(), 0);
677        assert_eq!(MessageType::BulkAccepted.flags(), 0);
678        assert_eq!(MessageType::BulkCredit.flags(), 0);
679        assert_eq!(MessageType::BulkFinish.flags(), 0);
680        assert_eq!(MessageType::BulkCancel.flags(), 0);
681        assert_eq!(MessageType::ExecStarted.flags(), 0);
682        assert_eq!(MessageType::ExecStdin.flags(), 0);
683        assert_eq!(MessageType::ExecStdout.flags(), 0);
684        assert_eq!(MessageType::ExecStderr.flags(), 0);
685        assert_eq!(MessageType::ExecResize.flags(), 0);
686        assert_eq!(MessageType::ExecSignal.flags(), 0);
687        assert_eq!(MessageType::FsData.flags(), 0);
688        assert_eq!(MessageType::TcpConnected.flags(), 0);
689        assert_eq!(MessageType::TcpData.flags(), 0);
690        assert_eq!(MessageType::TcpEof.flags(), 0);
691        assert_eq!(MessageType::TcpClose.flags(), 0);
692    }
693
694    #[test]
695    fn test_additive_fields_keep_old_and_new_compatible() {
696        // The core backward-compatibility guarantee from VERSIONING.md: a new,
697        // always-optional field is safe in both directions across a version skew.
698        use serde::{Deserialize, Serialize};
699
700        // A payload as it existed at an older generation.
701        #[derive(Serialize, Deserialize)]
702        struct Old {
703            a: u32,
704            b: u32,
705        }
706
707        // The same payload after a later generation added `c` (optional).
708        #[derive(Serialize, Deserialize, Debug, PartialEq)]
709        struct New {
710            a: u32,
711            b: u32,
712            #[serde(default)]
713            c: u32,
714        }
715
716        // New sender -> old receiver: the unknown `c` is ignored, not an error.
717        let mut new_bytes = Vec::new();
718        ciborium::into_writer(&New { a: 1, b: 2, c: 3 }, &mut new_bytes).unwrap();
719        let as_old: Old = ciborium::from_reader(&new_bytes[..]).unwrap();
720        assert_eq!((as_old.a, as_old.b), (1, 2));
721
722        // Old sender -> new receiver: the missing `c` falls back to its default.
723        let mut old_bytes = Vec::new();
724        ciborium::into_writer(&Old { a: 1, b: 2 }, &mut old_bytes).unwrap();
725        let as_new: New = ciborium::from_reader(&old_bytes[..]).unwrap();
726        assert_eq!(as_new, New { a: 1, b: 2, c: 0 });
727    }
728
729    #[test]
730    fn test_is_available_at() {
731        // Exec is in the generation-1 baseline: available to every peer.
732        assert!(MessageType::ExecRequest.is_available_at(1));
733        assert!(MessageType::ExecRequest.is_available_at(2));
734        assert!(MessageType::ExecRequest.is_available_at(PROTOCOL_VERSION));
735        // Filesystem requires generation 2: unavailable to a legacy (gen 1) peer.
736        assert!(!MessageType::FsRequest.is_available_at(1));
737        assert!(MessageType::FsRequest.is_available_at(2));
738        assert!(MessageType::FsRequest.is_available_at(PROTOCOL_VERSION));
739        // Ping/touch are generation-6 core capabilities.
740        assert!(!MessageType::Ping.is_available_at(5));
741        assert!(MessageType::Ping.is_available_at(6));
742        assert!(MessageType::Ping.is_available_at(PROTOCOL_VERSION));
743        // Bootstrap is internal to generation-7 host/agent boot.
744        assert!(!MessageType::Bootstrap.is_available_at(6));
745        // Released generation-8 agents support bulk I/O, not workload latching.
746        assert!(!MessageType::WorkloadFreeze.is_available_at(8));
747        assert!(MessageType::WorkloadFreeze.is_available_at(PROTOCOL_VERSION));
748        assert!(MessageType::Bootstrap.is_available_at(7));
749        // Raw bulk controls are generation-8 only. A bootstrap-capable
750        // generation-7 peer must remain on the framed compatibility path.
751        assert!(!MessageType::BulkAccepted.is_available_at(7));
752        assert!(MessageType::BulkAccepted.is_available_at(8));
753    }
754
755    #[test]
756    fn test_min_protocol_version_per_type() {
757        // Core and exec types are the generation-1 baseline: usable on every
758        // runtime we still talk to, including the pre-0.5 legacy one.
759        let baseline = [
760            MessageType::Ready,
761            MessageType::InitResolved,
762            MessageType::InitAck,
763            MessageType::Shutdown,
764            MessageType::RelayClientDisconnected,
765            MessageType::ClockSync,
766            MessageType::ExecRequest,
767            MessageType::ExecStarted,
768            MessageType::ExecStdin,
769            MessageType::ExecStdinError,
770            MessageType::ExecStdout,
771            MessageType::ExecStderr,
772            MessageType::ExecExited,
773            MessageType::ExecFailed,
774            MessageType::ExecResize,
775            MessageType::ExecSignal,
776        ];
777        for mt in &baseline {
778            assert_eq!(mt.min_protocol_version(), 1, "{mt:?} should be v1 baseline");
779        }
780
781        // Filesystem streaming did not exist in the pre-0.5 legacy protocol, so
782        // these require a post-legacy generation.
783        for mt in [
784            MessageType::FsRequest,
785            MessageType::FsResponse,
786            MessageType::FsData,
787        ] {
788            assert_eq!(mt.min_protocol_version(), 2, "{mt:?} should require gen 2");
789        }
790
791        for mt in [
792            MessageType::Ping,
793            MessageType::Pong,
794            MessageType::Touch,
795            MessageType::Touched,
796        ] {
797            assert_eq!(mt.min_protocol_version(), 6, "{mt:?} should require gen 6");
798        }
799
800        assert_eq!(MessageType::Bootstrap.min_protocol_version(), 7);
801        for mt in [
802            MessageType::BulkAccepted,
803            MessageType::BulkCredit,
804            MessageType::BulkFinish,
805            MessageType::BulkCancel,
806        ] {
807            assert_eq!(mt.min_protocol_version(), 8, "{mt:?} should require gen 8");
808        }
809
810        for mt in [
811            MessageType::WorkloadFreeze,
812            MessageType::WorkloadFrozen,
813            MessageType::WorkloadThaw,
814            MessageType::WorkloadThawed,
815            MessageType::WorkloadTransportCredit,
816        ] {
817            assert_eq!(mt.min_protocol_version(), 9, "{mt:?} should require gen 9");
818        }
819
820        // Every current type must be sendable to a current peer.
821        assert!(MessageType::FsRequest.min_protocol_version() <= PROTOCOL_VERSION);
822    }
823
824    #[test]
825    fn test_message_new_computes_flags() {
826        let msg = Message::new(MessageType::ExecRequest, 1, Vec::new());
827        assert_eq!(msg.flags, FLAG_SESSION_START);
828
829        let msg = Message::new(MessageType::ExecStdout, 1, Vec::new());
830        assert_eq!(msg.flags, 0);
831    }
832}