Skip to main content

smolvm_protocol/
lib.rs

1//! Protocol types for smolvm host-guest communication.
2//!
3//! This crate defines the wire protocol for vsock communication between
4//! the smolvm host and the guest agent (smolvm-agent).
5//!
6//! # Protocol Overview
7//!
8//! Communication uses JSON-encoded messages over vsock. Each message is
9//! prefixed with a 4-byte big-endian length header.
10//!
11//! ```text
12//! +----------------+-------------------+
13//! | Length (4 BE)  | JSON payload      |
14//! +----------------+-------------------+
15//! ```
16
17#![deny(missing_docs)]
18
19use serde::{Deserialize, Serialize};
20
21pub mod forkpoint;
22pub mod guest_env;
23pub mod image_ref;
24pub mod publish_socket;
25pub mod retry;
26pub mod secrets;
27
28pub use image_ref::{image_repo, normalize_image_ref};
29pub use secrets::{SecretRef, SecretSourceKind};
30
31/// Serde helper for encoding `Vec<u8>` as a base64 string in JSON.
32///
33/// Without this, serde_json serializes `Vec<u8>` as a JSON array of numbers
34/// (e.g., `[104,101,108,108,111]`), which inflates binary data by ~4x.
35/// Base64 encoding reduces this to ~1.33x.
36pub mod base64_bytes {
37    use base64::{engine::general_purpose::STANDARD, Engine};
38    use serde::{Deserialize, Deserializer, Serializer};
39
40    /// Serialize `Vec<u8>` as a base64 string.
41    pub fn serialize<S: Serializer>(data: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
42        serializer.serialize_str(&STANDARD.encode(data))
43    }
44
45    /// Deserialize a base64 string into `Vec<u8>`.
46    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
47        let s = String::deserialize(deserializer)?;
48        STANDARD.decode(&s).map_err(serde::de::Error::custom)
49    }
50}
51
52/// Protocol version.
53pub const PROTOCOL_VERSION: u32 = 1;
54
55/// virtiofs tag under which the host exposes the Rosetta 2 Linux runtime to the
56/// guest. Shared host↔guest so the launcher's `krun_add_virtiofs` tag and the
57/// guest agent's `mount -t virtiofs` source can't drift apart.
58pub const ROSETTA_TAG: &str = "rosetta";
59
60/// Guest mount point for the Rosetta 2 Linux runtime. The ptrace wrapper execs
61/// `<ROSETTA_GUEST_PATH>/rosetta` (the translator), so this path is baked into
62/// both the wrapper and the `binfmt_misc` registration.
63pub const ROSETTA_GUEST_PATH: &str = "/mnt/rosetta";
64
65/// Maximum frame size (32 MB - layer exports use chunked streaming).
66pub const MAX_FRAME_SIZE: u32 = 32 * 1024 * 1024;
67
68/// Chunk size for streaming layer data (~16 MB raw, ~21 MB as base64 JSON).
69pub const LAYER_CHUNK_SIZE: usize = 16 * 1024 * 1024;
70
71/// Files at or below this size are written with a single `FileWrite`
72/// message. Larger files must stream via
73/// `FileWriteBegin` + `FileWriteChunk` so no single frame approaches
74/// [`MAX_FRAME_SIZE`] (base64 + JSON inflation is ~1.4x).
75///
76/// Chosen to keep the single-shot frame comfortably under the frame
77/// limit while preserving the fast-path latency for small config
78/// files / scripts / keys.
79pub const FILE_WRITE_SINGLE_SHOT_MAX: usize = 1024 * 1024;
80
81/// Payload bytes per streaming upload chunk. Deliberately small —
82/// equal to [`FILE_WRITE_SINGLE_SHOT_MAX`] — so each chunk's encoded
83/// frame (~1.4 MB) fits inside typical kernel Unix-socket send
84/// buffers (`SO_SNDBUF` defaults on the order of 200–256 KiB but
85/// can grow). Larger chunks would force `write_all` to spin waiting
86/// for the agent to drain, and any latency spike trips the 10 s
87/// write timeout with `EAGAIN` — exactly the failure David
88/// reproduced before this fix landed.
89///
90/// Note: [`LAYER_CHUNK_SIZE`] is 16 MiB for agent→host (download)
91/// streaming, which works because the host side of the socket has
92/// more headroom than the guest side. Upload streaming is the
93/// asymmetric case and needs a smaller chunk.
94pub const FILE_WRITE_CHUNK_SIZE: usize = FILE_WRITE_SINGLE_SHOT_MAX;
95
96// The single-shot threshold must be <= the chunk size. They can be equal (a
97// 1 MiB file is a single shot; a 1 MiB + 1 byte file streams as two chunks),
98// but SINGLE_SHOT > CHUNK would be incoherent — a file slightly over the shot
99// threshold would have to stream as a single oversized chunk. Enforced at
100// compile time so a bad edit fails the build rather than a test.
101const _: () = assert!(FILE_WRITE_SINGLE_SHOT_MAX <= FILE_WRITE_CHUNK_SIZE);
102
103/// Hard ceiling on a single file transfer in either direction.
104///
105/// On the write path: enforced at `FileWriteBegin` by the agent —
106/// `total_size > FILE_TRANSFER_MAX_TOTAL` is rejected before any
107/// staging file is created.
108///
109/// On the read path: enforced by the host's `read_file` loop —
110/// after the first chunk that pushes the accumulated total past the
111/// cap, the call bails with an error and the partial buffer is
112/// dropped. This protects the host process from OOM if the guest
113/// (compromised or merely buggy) streams unbounded data.
114///
115/// 4 GiB matches the order-of-magnitude of the default overlay disk and the
116/// `gpu_vram_mib` cap. It is the compiled-in default. The host-side transfer
117/// paths (read/export, including `pack create --from-vm`) raise it at runtime
118/// via `SMOLVM_FILE_TRANSFER_MAX_BYTES` (see
119/// `agent::client::file_transfer_max_total`) so a VM snapshot whose overlay
120/// carries a large dependency tree (e.g. a torch + CUDA-wheels environment is
121/// ~5 GiB) can be packed without lowering the DoS bound for everyone else. The
122/// guest agent's write-path check runs inside the VM and cannot see that host
123/// env var, so it always enforces this const. Callers that need to move larger
124/// blobs routinely should stage via a virtiofs mount instead of `cp`.
125pub const FILE_TRANSFER_MAX_TOTAL: u64 = 4 * 1024 * 1024 * 1024;
126
127/// Filename of the virtiofs-visible marker the agent creates when it is
128/// ready to accept vsock connections.
129///
130/// The host polls for this file through its virtiofs mount of the guest
131/// rootfs. The agent writes it (and optionally a symlink from `/oldroot/`)
132/// during deferred init, just before opening the vsock listener.
133///
134/// Both sides must agree on this name; keeping it here prevents silent drift.
135pub const AGENT_READY_MARKER: &str = ".smolvm-ready";
136
137/// Well-known vsock ports.
138pub mod ports {
139    /// Control channel for workload VMs.
140    pub const WORKLOAD_CONTROL: u32 = 5000;
141    /// Log streaming from workload VMs.
142    pub const WORKLOAD_LOGS: u32 = 5001;
143    /// Agent control port (for OCI operations and management).
144    pub const AGENT_CONTROL: u32 = 6000;
145    /// SSH agent forwarding (host SSH_AUTH_SOCK bridged to guest).
146    pub const SSH_AGENT: u32 = 6001;
147    /// DNS filtering proxy (guest forwards DNS queries to host for filtering).
148    pub const DNS_FILTER: u32 = 6002;
149    /// Docker socket bridge: the guest listens on this vsock port and proxies
150    /// each connection to the in-guest `/var/run/docker.sock`, so the host can
151    /// reach the guest's Docker daemon over a host-side Unix socket
152    /// (`DOCKER_HOST=unix://…`). Inbound (host connects in), like the agent
153    /// control channel — unlike the outbound SSH/DNS/CUDA bridges.
154    pub const DOCKER: u32 = 6003;
155    /// CUDA-over-vsock (experimental): guest CUDA client forwards Driver-API
156    /// calls to a host CUDA server that runs them on the host NVIDIA GPU.
157    pub const CUDA: u32 = 7000;
158
159    /// Base vsock port for user-published Unix-socket bridges
160    /// (`--expose-socket` / `--mount-socket`). Each published socket is assigned
161    /// `PUBLISH_SOCKET_BASE + index`. Kept clear of the fixed ports above (and of
162    /// CUDA at 7000) so a reasonable number of sockets never collides.
163    pub const PUBLISH_SOCKET_BASE: u32 = 6100;
164
165    /// Maximum number of user-published sockets per VM. Bounds the vsock-port
166    /// window (`6100..6100+MAX`) below CUDA's 7000.
167    pub const PUBLISH_SOCKET_MAX: usize = 64;
168}
169
170/// vsock CID constants.
171pub mod cid {
172    /// Host CID (always 2).
173    pub const HOST: u32 = 2;
174    /// Guest CID (always 3 for the first/only guest).
175    pub const GUEST: u32 = 3;
176    /// Any CID (for listening).
177    pub const ANY: u32 = u32::MAX;
178}
179
180/// fsnotify event masks, mirroring the kernel's `FS_*` bits in
181/// `include/linux/fsnotify_backend.h`. Shared by the host watcher (which maps a
182/// host filesystem event to one of these) and the guest agent (which forwards
183/// the raw bits to `/proc/smolvm-fsnotify`). Only the subset relevant to
184/// file-watching tools is defined.
185pub mod fsnotify_mask {
186    /// File was modified.
187    pub const FS_MODIFY: u32 = 0x0000_0002;
188    /// Metadata changed (chmod/chown/utimes).
189    pub const FS_ATTRIB: u32 = 0x0000_0004;
190    /// Writable file was closed.
191    pub const FS_CLOSE_WRITE: u32 = 0x0000_0008;
192    /// File was moved away from the watched dir.
193    pub const FS_MOVED_FROM: u32 = 0x0000_0040;
194    /// File was moved into the watched dir.
195    pub const FS_MOVED_TO: u32 = 0x0000_0080;
196    /// Subfile was created.
197    pub const FS_CREATE: u32 = 0x0000_0100;
198    /// Subfile was deleted.
199    pub const FS_DELETE: u32 = 0x0000_0200;
200    /// Event occurred against a directory.
201    pub const FS_ISDIR: u32 = 0x4000_0000;
202}
203
204/// A single host-originated filesystem change to replay into the guest.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct FsNotifyEvent {
207    /// Guest-side absolute path the event occurred on (virtiofs staging path).
208    pub path: String,
209    /// `fsnotify_mask::FS_*` bitmask for the event.
210    pub mask: u32,
211}
212
213// ============================================================================
214// Agent Protocol (OCI Operations)
215// ============================================================================
216
217/// Agent request types (for image management and OCI operations).
218#[derive(Debug, Clone, Serialize, Deserialize)]
219#[serde(tag = "method", rename_all = "snake_case")]
220pub enum AgentRequest {
221    /// Ping to check if agent is alive.
222    Ping,
223
224    /// Inject host-originated fsnotify events into the guest.
225    ///
226    /// virtiofs does not deliver host-side file changes to the guest as
227    /// fsnotify/inotify events, so inotify-based hot-reload (Vite, webpack,
228    /// nodemon) never fires when a mounted file is edited on the host. The host
229    /// watches the mount source and sends the resulting events here; the agent
230    /// writes them to `/proc/smolvm-fsnotify`, which fires the matching event on
231    /// the guest inode so watchers on the (bind-mounted) container path wake up.
232    /// Each `path` is a guest-side absolute path (the virtiofs staging path),
233    /// `mask` an `fsnotify_mask::FS_*` bitmask.
234    FsNotify {
235        /// Host-originated filesystem changes to replay as guest fsnotify events.
236        #[serde(default)]
237        events: Vec<FsNotifyEvent>,
238    },
239
240    /// Pull an OCI image and extract layers.
241    Pull {
242        /// Image reference (e.g., "alpine:latest", "docker.io/library/ubuntu:22.04").
243        image: String,
244        /// OCI platform to pull (e.g., "linux/arm64", "linux/amd64").
245        oci_platform: Option<String>,
246        /// Optional registry authentication credentials.
247        #[serde(default, skip_serializing_if = "Option::is_none")]
248        auth: Option<RegistryAuth>,
249        /// Proxy URL applied to the registry client (sets HTTP_PROXY and HTTPS_PROXY).
250        #[serde(default, skip_serializing_if = "Option::is_none")]
251        proxy: Option<String>,
252        /// Comma-separated NO_PROXY list of hosts/CIDRs that bypass the proxy.
253        #[serde(default, skip_serializing_if = "Option::is_none")]
254        no_proxy: Option<String>,
255    },
256
257    /// Query if an image exists locally.
258    Query {
259        /// Image reference.
260        image: String,
261    },
262
263    /// List all cached images.
264    ListImages,
265
266    /// Run garbage collection on unused layers.
267    GarbageCollect {
268        /// If true, only report what would be deleted.
269        dry_run: bool,
270        /// If true, delete all image manifests and configs first,
271        /// making all layers unreferenced so they get collected.
272        #[serde(default)]
273        purge_all: bool,
274    },
275
276    /// Prepare overlay rootfs for a workload.
277    PrepareOverlay {
278        /// Image reference.
279        image: String,
280        /// Unique workload ID for the overlay.
281        workload_id: String,
282    },
283
284    /// Clean up overlay rootfs for a workload.
285    CleanupOverlay {
286        /// Workload ID to clean up.
287        workload_id: String,
288    },
289
290    /// Format the storage disk (first-time setup).
291    FormatStorage,
292
293    /// Get storage disk status.
294    StorageStatus,
295
296    /// Test network connectivity directly from the agent (not via chroot).
297    /// Used to debug TSI networking.
298    NetworkTest {
299        /// URL to test (e.g., "http://1.1.1.1")
300        url: String,
301    },
302
303    /// Shutdown the agent.
304    Shutdown,
305
306    /// Export a layer as a tar archive.
307    ///
308    /// Used by `smolvm pack` to extract OCI layers for packaging.
309    /// The agent streams the layer tar data back via LayerData responses.
310    ExportLayer {
311        /// Image digest (sha256:...).
312        image_digest: String,
313        /// Layer index (0-based).
314        layer_index: usize,
315    },
316
317    /// Execute a command directly in the VM (not in a container).
318    ///
319    /// This runs the command in the agent's Alpine rootfs without any
320    /// container isolation. Useful for VM-level operations and debugging.
321    VmExec {
322        /// Command and arguments.
323        command: Vec<String>,
324        /// Environment variables.
325        #[serde(default)]
326        env: Vec<(String, String)>,
327        /// Working directory in the VM.
328        workdir: Option<String>,
329        /// Timeout in milliseconds.
330        #[serde(default)]
331        timeout_ms: Option<u64>,
332        /// Interactive mode - stream I/O instead of buffering.
333        #[serde(default)]
334        interactive: bool,
335        /// Allocate a pseudo-TTY for the command.
336        #[serde(default)]
337        tty: bool,
338        /// Background mode - spawn and return PID immediately without waiting.
339        #[serde(default)]
340        background: bool,
341        /// Data to pipe to the command's stdin.
342        #[serde(default)]
343        stdin_data: Option<String>,
344    },
345
346    /// Run a command in an image's rootfs.
347    ///
348    /// This prepares an overlay, chroots into it, and executes the command.
349    /// Returns stdout, stderr, and exit code when the command completes.
350    Run {
351        /// Image reference (must be pulled first).
352        image: String,
353        /// Command and arguments.
354        command: Vec<String>,
355        /// Environment variables.
356        #[serde(default)]
357        env: Vec<(String, String)>,
358        /// Working directory inside the rootfs.
359        workdir: Option<String>,
360        /// User inside the rootfs. If omitted, the OCI image default applies.
361        #[serde(default, skip_serializing_if = "Option::is_none")]
362        user: Option<String>,
363        /// Volume mounts to bind into the container.
364        /// Each tuple is (virtiofs_tag, container_path, read_only).
365        #[serde(default)]
366        mounts: Vec<(String, String, bool)>,
367        /// Timeout in milliseconds. If the command exceeds this duration,
368        /// it will be killed and return exit code 124.
369        #[serde(default)]
370        timeout_ms: Option<u64>,
371        /// Interactive mode - stream I/O instead of buffering.
372        /// When true, output is streamed via Stdout/Stderr responses,
373        /// and stdin can be sent via the Stdin request.
374        #[serde(default)]
375        interactive: bool,
376        /// Allocate a pseudo-TTY for the command.
377        /// Enables terminal features like colors, line editing, and signal handling.
378        #[serde(default)]
379        tty: bool,
380        /// Detached mode — start the container and return immediately with the
381        /// container ID. Only meaningful when `persistent_overlay_id` is set.
382        /// Returns a `Completed` response with `stdout` containing the container ID.
383        #[serde(default)]
384        detached: bool,
385        /// Run the workload as an unprivileged container: restricted capabilities,
386        /// read-only cgroup, and no extra tmpfs. The default (false) is "VM-grade"
387        /// — since the microVM is the isolation boundary, the workload gets a full
388        /// capability set and the mounts an init system needs (so any image, incl.
389        /// systemd, boots). Opt in for defense-in-depth when running untrusted code.
390        #[serde(default)]
391        unprivileged: bool,
392        /// If set, use a persistent overlay that survives across exec sessions.
393        /// The overlay is identified by this ID (typically the machine name)
394        /// and reused on subsequent runs. If not set, an ephemeral overlay is
395        /// created and destroyed after the run.
396        #[serde(default, skip_serializing_if = "Option::is_none")]
397        persistent_overlay_id: Option<String>,
398        /// Data to pipe to the command's stdin (non-interactive runs only).
399        /// The pipe is closed after writing, so the command sees EOF.
400        #[serde(default, skip_serializing_if = "Option::is_none")]
401        stdin_data: Option<String>,
402        /// Spawn the container and return immediately with the crun PID.
403        /// The container runs detached; stdout/stderr go to /dev/null.
404        /// Incompatible with `interactive` and `tty`.
405        #[serde(default)]
406        background: bool,
407    },
408
409    /// Send stdin data to a running interactive command.
410    Stdin {
411        /// Input data to send to the command's stdin.
412        #[serde(with = "base64_bytes")]
413        data: Vec<u8>,
414    },
415
416    /// Resize the PTY window (for TTY mode).
417    Resize {
418        /// New width in columns.
419        cols: u16,
420        /// New height in rows.
421        rows: u16,
422    },
423
424    // ========================================================================
425    // File I/O
426    // ========================================================================
427    /// Write a file inside the VM in a single message.
428    ///
429    /// Use only for files up to [`FILE_WRITE_SINGLE_SHOT_MAX`]. Larger
430    /// files must stream via [`Self::FileWriteBegin`] +
431    /// [`Self::FileWriteChunk`] to avoid exceeding [`MAX_FRAME_SIZE`]
432    /// after base64 + JSON inflation.
433    FileWrite {
434        /// Absolute path in the VM filesystem.
435        path: String,
436        /// File contents.
437        #[serde(with = "base64_bytes")]
438        data: Vec<u8>,
439        /// File mode (e.g., 0o644). None = default (0644).
440        #[serde(default)]
441        mode: Option<u32>,
442    },
443
444    /// Open a streaming file upload session on this connection.
445    ///
446    /// Must be followed by one or more [`Self::FileWriteChunk`]
447    /// requests. The final chunk sets `done: true` to finalize.
448    /// Dropping the connection (or sending any non-chunk request)
449    /// before `done` aborts the session and leaves no partial file
450    /// at `path`.
451    ///
452    /// Sessions are per-connection — one session at a time.
453    FileWriteBegin {
454        /// Absolute path in the VM filesystem.
455        path: String,
456        /// File mode (e.g., 0o644). None = default (0644).
457        #[serde(default)]
458        mode: Option<u32>,
459        /// Expected total size in bytes. Rejected if it exceeds
460        /// [`FILE_TRANSFER_MAX_TOTAL`]. The agent uses this for an
461        /// early-fail check only; the actual size written is the sum
462        /// of chunk byte lengths.
463        total_size: u64,
464    },
465
466    /// Append a chunk to the currently open streaming upload.
467    /// If `done` is true, the agent fsyncs and atomically renames the
468    /// staging file onto the target path.
469    FileWriteChunk {
470        /// Chunk bytes. Typically [`FILE_WRITE_CHUNK_SIZE`] except
471        /// for the last chunk.
472        #[serde(with = "base64_bytes")]
473        data: Vec<u8>,
474        /// True on the final chunk; closes and renames the staging
475        /// file. False on intermediate chunks.
476        done: bool,
477    },
478
479    /// Read a file from the VM.
480    FileRead {
481        /// Absolute path in the VM filesystem.
482        path: String,
483    },
484
485    /// Create (without starting) a Kubernetes pod container whose rootfs is a
486    /// virtiofs-shared host directory (containerd snapshotter output) and whose
487    /// process definition comes from the host's OCI config. The agent builds
488    /// its crun bundle around the shared rootfs; nothing runs until
489    /// `PodStart`. Part of the containerd shim v2 datapath
490    /// (docs/kubernetes-runtime.md).
491    PodCreate {
492        /// Container ID (containerd task id).
493        id: String,
494        /// Rootfs path relative to the sandbox's shared virtiofs mount. The
495        /// shim boots the sandbox VM with ONE shared dir and bind-mounts each
496        /// container's rootfs under it (virtiofs shares are fixed at boot, but
497        /// pod containers are created afterwards), so the guest resolves this
498        /// as `<sandbox-share-mount>/<rootfs_rel>`.
499        rootfs_rel: String,
500        /// The host OCI runtime spec (config.json bytes). The agent extracts
501        /// process/env/cwd/user/mounts/resources and grafts them onto its own
502        /// guest bundle template; host-specific namespaces/paths are ignored.
503        spec_json: String,
504        /// Allocate a PTY for the init process.
505        #[serde(default)]
506        tty: bool,
507    },
508
509    /// Start a pod container created by `PodCreate` (or an exec process
510    /// registered by `PodExec`), streaming its I/O on THIS connection:
511    /// `Started` → `Stdout`/`Stderr`... → `Exited`. Stdin arrives via `Stdin`
512    /// requests; PTY resize via `Resize`.
513    PodStart {
514        /// Container ID.
515        id: String,
516        /// Exec process to start instead of the init process.
517        #[serde(default, skip_serializing_if = "Option::is_none")]
518        exec_id: Option<String>,
519    },
520
521    /// Register an exec process for a running pod container. Started later by
522    /// `PodStart { exec_id }`.
523    PodExec {
524        /// Container ID.
525        id: String,
526        /// Exec process ID (unique within the container).
527        exec_id: String,
528        /// OCI Process JSON (containerd's ExecProcessRequest spec).
529        process_json: String,
530        /// Allocate a PTY for the exec process.
531        #[serde(default)]
532        tty: bool,
533    },
534
535    /// Signal a pod container's init process (or one exec process).
536    PodSignal {
537        /// Container ID.
538        id: String,
539        /// Exec process to signal instead of init.
540        #[serde(default, skip_serializing_if = "Option::is_none")]
541        exec_id: Option<String>,
542        /// Signal number (SIGKILL = 9, SIGTERM = 15, ...).
543        signal: u32,
544        /// Signal the whole container process group.
545        #[serde(default)]
546        all: bool,
547    },
548
549    /// List PIDs inside a pod container (guest view).
550    PodPids {
551        /// Container ID.
552        id: String,
553    },
554
555    /// Sample a pod container's resource usage (guest view). The agent reads the
556    /// container's process tree from /proc (there is no per-container cgroup); the
557    /// shim maps the reply into containerd's cgroups metrics for CRI stats.
558    PodStats {
559        /// Container ID.
560        id: String,
561    },
562
563    /// Remove a pod container's (or exec process's) guest resources after
564    /// exit: bundle, cgroup, PTY. Exit status was already streamed by
565    /// `PodStart`'s `Exited`.
566    PodDelete {
567        /// Container ID.
568        id: String,
569        /// Exec process to remove instead of the whole container.
570        #[serde(default, skip_serializing_if = "Option::is_none")]
571        exec_id: Option<String>,
572    },
573}
574
575impl AgentRequest {
576    /// A log-safe one-line summary of the request.
577    ///
578    /// This string is written to the machine's console log, which is exposed
579    /// over the logs API — so it must NEVER include credential- or data-bearing
580    /// fields: registry `auth`, `env` (which can carry host-resolved secrets),
581    /// `proxy` (may embed credentials), or `data` (file/stdin bytes). Only the
582    /// variant name plus a non-secret identifier (image) is emitted.
583    ///
584    /// The match is exhaustive with no catch-all on purpose: adding a new
585    /// variant forces a compile error here, so redaction is a deliberate
586    /// decision rather than an accidental leak in some future request type.
587    pub fn log_summary(&self) -> String {
588        match self {
589            AgentRequest::Ping => "Ping".into(),
590            AgentRequest::FsNotify { events } => format!("FsNotify {{ count: {} }}", events.len()),
591            AgentRequest::Pull { image, .. } => format!("Pull {{ image: {image} }}"),
592            AgentRequest::Query { image, .. } => format!("Query {{ image: {image} }}"),
593            AgentRequest::ListImages => "ListImages".into(),
594            AgentRequest::GarbageCollect { .. } => "GarbageCollect".into(),
595            AgentRequest::PrepareOverlay { .. } => "PrepareOverlay".into(),
596            AgentRequest::CleanupOverlay { .. } => "CleanupOverlay".into(),
597            AgentRequest::FormatStorage => "FormatStorage".into(),
598            AgentRequest::StorageStatus => "StorageStatus".into(),
599            AgentRequest::NetworkTest { .. } => "NetworkTest".into(),
600            AgentRequest::Shutdown => "Shutdown".into(),
601            AgentRequest::ExportLayer { .. } => "ExportLayer".into(),
602            AgentRequest::VmExec { .. } => "VmExec".into(),
603            AgentRequest::Run { image, .. } => format!("Run {{ image: {image} }}"),
604            AgentRequest::Stdin { .. } => "Stdin".into(),
605            AgentRequest::Resize { .. } => "Resize".into(),
606            AgentRequest::FileWrite { .. } => "FileWrite".into(),
607            AgentRequest::FileWriteBegin { .. } => "FileWriteBegin".into(),
608            AgentRequest::FileWriteChunk { .. } => "FileWriteChunk".into(),
609            AgentRequest::FileRead { .. } => "FileRead".into(),
610            // Pod requests: spec/process JSON may carry env secrets — emit ids only.
611            AgentRequest::PodCreate { id, .. } => format!("PodCreate {{ id: {id} }}"),
612            AgentRequest::PodStart { id, exec_id } => match exec_id {
613                Some(e) => format!("PodStart {{ id: {id}, exec: {e} }}"),
614                None => format!("PodStart {{ id: {id} }}"),
615            },
616            AgentRequest::PodExec { id, exec_id, .. } => {
617                format!("PodExec {{ id: {id}, exec: {exec_id} }}")
618            }
619            AgentRequest::PodSignal {
620                id, signal, all, ..
621            } => format!("PodSignal {{ id: {id}, signal: {signal}, all: {all} }}"),
622            AgentRequest::PodPids { id } => format!("PodPids {{ id: {id} }}"),
623            AgentRequest::PodStats { id } => format!("PodStats {{ id: {id} }}"),
624            AgentRequest::PodDelete { id, exec_id } => match exec_id {
625                Some(e) => format!("PodDelete {{ id: {id}, exec: {e} }}"),
626                None => format!("PodDelete {{ id: {id} }}"),
627            },
628        }
629    }
630}
631
632/// Agent response types.
633#[derive(Debug, Clone, Serialize, Deserialize)]
634#[serde(tag = "status", rename_all = "snake_case")]
635pub enum AgentResponse {
636    /// Operation completed successfully.
637    Ok {
638        /// Response data (varies by request type).
639        #[serde(default, skip_serializing_if = "Option::is_none")]
640        data: Option<serde_json::Value>,
641    },
642
643    /// Pong response to ping.
644    Pong {
645        /// Protocol version.
646        version: u32,
647    },
648
649    /// Progress update (for long operations like pull).
650    Progress {
651        /// Human-readable message.
652        message: String,
653        /// Completion percentage (0-100).
654        #[serde(default, skip_serializing_if = "Option::is_none")]
655        percent: Option<u8>,
656        /// Current layer being processed.
657        #[serde(default, skip_serializing_if = "Option::is_none")]
658        layer: Option<String>,
659    },
660
661    /// Operation failed.
662    Error {
663        /// Error message.
664        message: String,
665        /// Error code (for programmatic handling).
666        #[serde(default, skip_serializing_if = "Option::is_none")]
667        code: Option<String>,
668    },
669
670    /// Command execution completed (non-interactive mode).
671    Completed {
672        /// Exit code from the command.
673        exit_code: i32,
674        /// Standard output (may be truncated). `Vec<u8>` preserves binary
675        /// output (image bytes, tarballs, etc.) that would be truncated by
676        /// `String` at the first non-UTF-8 byte. Serialized as base64 JSON
677        /// string — the same format as the streaming `Stdout` variant.
678        #[serde(with = "base64_bytes")]
679        stdout: Vec<u8>,
680        /// Standard error (may be truncated).
681        #[serde(with = "base64_bytes")]
682        stderr: Vec<u8>,
683    },
684
685    /// Command started (interactive mode).
686    /// Indicates the command is running and ready to receive stdin.
687    Started,
688
689    /// Stdout data from a running command (interactive mode).
690    Stdout {
691        /// Output data.
692        #[serde(with = "base64_bytes")]
693        data: Vec<u8>,
694    },
695
696    /// Stderr data from a running command (interactive mode).
697    Stderr {
698        /// Error output data.
699        #[serde(with = "base64_bytes")]
700        data: Vec<u8>,
701    },
702
703    /// Command exited (interactive mode).
704    Exited {
705        /// Exit code from the command.
706        exit_code: i32,
707        /// The container was terminated by the cgroup OOM killer. The shim
708        /// turns this into a TaskOOM event so the CRI reports
709        /// `reason=OOMKilled`. Only ever set on a pod container's init exit.
710        #[serde(default)]
711        oom: bool,
712    },
713
714    /// PIDs inside a pod container (`PodPids` reply).
715    Pids {
716        /// Guest PIDs, container-init first when known.
717        pids: Vec<u32>,
718    },
719
720    /// Resource usage sample for a pod container (`PodStats` reply). Summed over
721    /// the container's process tree read from /proc (no per-container cgroup).
722    Stats {
723        /// Cumulative CPU time of the process tree, in nanoseconds.
724        cpu_usage_ns: u64,
725        /// Resident memory of the process tree, in bytes.
726        memory_bytes: u64,
727    },
728
729    /// Streaming binary-data chunk.
730    ///
731    /// Used by every streaming download direction: the agent sends
732    /// one or more `DataChunk` responses in sequence, with `done: true`
733    /// on the final chunk. Current producers: `ExportLayer` and
734    /// `FileRead`.
735    ///
736    /// Payload size per chunk should stay under
737    /// [`LAYER_CHUNK_SIZE`] so the encoded frame (~1.33× after
738    /// base64) fits inside [`MAX_FRAME_SIZE`] with JSON overhead to
739    /// spare.
740    DataChunk {
741        /// Chunk bytes. Empty allowed on the final frame (common for
742        /// EOF-on-clean-boundary cases).
743        #[serde(with = "base64_bytes")]
744        data: Vec<u8>,
745        /// True on the final chunk of the stream.
746        done: bool,
747    },
748}
749
750// ============================================================================
751// Error Code Constants
752// ============================================================================
753//
754// Standard error codes for AgentResponse::Error. Using constants ensures
755// consistency across the codebase and makes error handling more reliable.
756
757/// Error codes for agent responses.
758pub mod error_codes {
759    /// Request payload was invalid or malformed.
760    pub const INVALID_REQUEST: &str = "INVALID_REQUEST";
761    /// Requested resource was not found.
762    pub const NOT_FOUND: &str = "NOT_FOUND";
763    /// Internal error during operation.
764    pub const INTERNAL_ERROR: &str = "INTERNAL_ERROR";
765    /// Image pull operation failed.
766    pub const PULL_FAILED: &str = "PULL_FAILED";
767    /// Image query operation failed.
768    pub const QUERY_FAILED: &str = "QUERY_FAILED";
769    /// Command execution failed.
770    pub const RUN_FAILED: &str = "RUN_FAILED";
771    /// Command execution failed in container.
772    pub const EXEC_FAILED: &str = "EXEC_FAILED";
773    /// Process spawn failed.
774    pub const SPAWN_FAILED: &str = "SPAWN_FAILED";
775    /// Mount operation failed.
776    pub const MOUNT_FAILED: &str = "MOUNT_FAILED";
777    /// File I/O operation failed.
778    pub const FILE_IO_FAILED: &str = "FILE_IO_FAILED";
779    /// Overlay filesystem operation failed.
780    pub const OVERLAY_FAILED: &str = "OVERLAY_FAILED";
781    /// Cleanup operation failed.
782    pub const CLEANUP_FAILED: &str = "CLEANUP_FAILED";
783    /// Storage format operation failed.
784    pub const FORMAT_FAILED: &str = "FORMAT_FAILED";
785    /// Storage status query failed.
786    pub const STATUS_FAILED: &str = "STATUS_FAILED";
787    /// List operation failed.
788    pub const LIST_FAILED: &str = "LIST_FAILED";
789    /// Garbage collection failed.
790    pub const GC_FAILED: &str = "GC_FAILED";
791    /// Container creation failed.
792    pub const CREATE_FAILED: &str = "CREATE_FAILED";
793    /// Container start failed.
794    pub const START_FAILED: &str = "START_FAILED";
795    /// Container stop failed.
796    pub const STOP_FAILED: &str = "STOP_FAILED";
797    /// Container delete failed.
798    pub const DELETE_FAILED: &str = "DELETE_FAILED";
799    /// Export operation failed.
800    pub const EXPORT_FAILED: &str = "EXPORT_FAILED";
801    /// Serialization error.
802    pub const SERIALIZATION_ERROR: &str = "SERIALIZATION_ERROR";
803    /// Message size exceeds maximum.
804    pub const MESSAGE_TOO_LARGE: &str = "MESSAGE_TOO_LARGE";
805    /// Process wait operation failed.
806    pub const WAIT_FAILED: &str = "WAIT_FAILED";
807}
808
809impl AgentResponse {
810    /// Create an error response with the given message and code.
811    ///
812    /// # Example
813    ///
814    /// ```
815    /// use smolvm_protocol::{AgentResponse, error_codes};
816    ///
817    /// let response = AgentResponse::error("image not found", error_codes::NOT_FOUND);
818    /// ```
819    pub fn error(message: impl Into<String>, code: &str) -> Self {
820        AgentResponse::Error {
821            message: message.into(),
822            code: Some(code.to_string()),
823        }
824    }
825
826    /// Create an error response from a Result's error, with the given code.
827    ///
828    /// # Example
829    ///
830    /// ```ignore
831    /// let response = some_operation()
832    ///     .map(|data| AgentResponse::ok_with_data(data))
833    ///     .unwrap_or_else(|e| AgentResponse::from_err(e, error_codes::PULL_FAILED));
834    /// ```
835    pub fn from_err<E: std::fmt::Display>(err: E, code: &str) -> Self {
836        AgentResponse::Error {
837            message: err.to_string(),
838            code: Some(code.to_string()),
839        }
840    }
841
842    /// Create an Ok response with optional JSON data.
843    pub fn ok(data: Option<serde_json::Value>) -> Self {
844        AgentResponse::Ok { data }
845    }
846
847    /// Create an Ok response with JSON-serializable data.
848    ///
849    /// Returns an error response if serialization fails.
850    pub fn ok_with_data<T: serde::Serialize>(data: T) -> Self {
851        match serde_json::to_value(data) {
852            Ok(value) => AgentResponse::Ok { data: Some(value) },
853            Err(e) => AgentResponse::error(
854                format!("failed to serialize response: {}", e),
855                error_codes::SERIALIZATION_ERROR,
856            ),
857        }
858    }
859
860    /// Convert a Result into an AgentResponse.
861    ///
862    /// On success, serializes the value to JSON. On error, creates an error response.
863    ///
864    /// # Example
865    ///
866    /// ```ignore
867    /// let response = AgentResponse::from_result(
868    ///     storage::pull_image(image),
869    ///     error_codes::PULL_FAILED,
870    /// );
871    /// ```
872    pub fn from_result<T, E>(result: Result<T, E>, error_code: &str) -> Self
873    where
874        T: serde::Serialize,
875        E: std::fmt::Display,
876    {
877        match result {
878            Ok(data) => Self::ok_with_data(data),
879            Err(e) => Self::from_err(e, error_code),
880        }
881    }
882}
883
884/// Image information returned by Query/ListImages.
885#[derive(Debug, Clone, Serialize, Deserialize)]
886pub struct ImageInfo {
887    /// Image reference.
888    pub reference: String,
889    /// Image digest (sha256:...).
890    pub digest: String,
891    /// Image size in bytes.
892    pub size: u64,
893    /// Creation timestamp (ISO 8601).
894    pub created: Option<String>,
895    /// Platform architecture.
896    pub architecture: String,
897    /// Platform OS.
898    pub os: String,
899    /// Number of layers.
900    pub layer_count: usize,
901    /// Layer digests in order.
902    pub layers: Vec<String>,
903    /// Image entrypoint (from OCI config).
904    #[serde(default)]
905    pub entrypoint: Vec<String>,
906    /// Image default command (from OCI config).
907    #[serde(default)]
908    pub cmd: Vec<String>,
909    /// Image environment variables (from OCI config).
910    #[serde(default)]
911    pub env: Vec<String>,
912    /// Image working directory (from OCI config).
913    #[serde(default)]
914    pub workdir: Option<String>,
915    /// Image default user (from OCI config).
916    #[serde(default)]
917    pub user: Option<String>,
918}
919
920/// Overlay preparation result.
921#[derive(Debug, Clone, Serialize, Deserialize)]
922pub struct OverlayInfo {
923    /// Path to the merged overlay rootfs.
924    pub rootfs_path: String,
925    /// Path to the upper (writable) directory.
926    pub upper_path: String,
927    /// Path to the work directory.
928    pub work_path: String,
929}
930
931/// Storage status information.
932#[derive(Debug, Clone, Serialize, Deserialize)]
933pub struct StorageStatus {
934    /// Whether the storage is formatted and ready.
935    pub ready: bool,
936    /// Total size in bytes.
937    pub total_bytes: u64,
938    /// Used size in bytes.
939    pub used_bytes: u64,
940    /// Number of cached layers.
941    pub layer_count: usize,
942    /// Number of cached images.
943    pub image_count: usize,
944}
945
946/// Registry authentication credentials for pulling images.
947///
948/// `Debug` is hand-written to redact the password: this value is carried inside
949/// `AgentRequest::Pull`, and any `{:?}` of that request (e.g. a tracing span)
950/// would otherwise serialize the token verbatim into the machine's console log,
951/// which is exposed over the logs API.
952#[derive(Clone, Serialize, Deserialize)]
953pub struct RegistryAuth {
954    /// Username for authentication.
955    pub username: String,
956    /// Password or token for authentication.
957    pub password: String,
958}
959
960impl std::fmt::Debug for RegistryAuth {
961    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
962        f.debug_struct("RegistryAuth")
963            .field("username", &self.username)
964            .field("password", &"***")
965            .finish()
966    }
967}
968
969// ============================================================================
970// Workload VM Protocol (Command Execution)
971// ============================================================================
972
973/// Messages from host to workload VM.
974#[derive(Debug, Clone, Serialize, Deserialize)]
975#[serde(tag = "type", rename_all = "snake_case")]
976pub enum HostMessage {
977    /// Authentication request.
978    Auth {
979        /// Authentication token (base64).
980        token: String,
981        /// Protocol version.
982        protocol_version: u32,
983    },
984
985    /// Run a command.
986    Run {
987        /// Request ID for correlating responses.
988        request_id: u64,
989        /// Command and arguments.
990        command: Vec<String>,
991        /// Environment variables.
992        env: Vec<(String, String)>,
993        /// Working directory.
994        workdir: Option<String>,
995    },
996
997    /// Execute a command in running VM.
998    Exec {
999        /// Request ID.
1000        request_id: u64,
1001        /// Command and arguments.
1002        command: Vec<String>,
1003        /// Allocate a TTY.
1004        tty: bool,
1005    },
1006
1007    /// Send a signal to a running command.
1008    Signal {
1009        /// Request ID of the command.
1010        request_id: u64,
1011        /// Signal number.
1012        signal: i32,
1013    },
1014
1015    /// Request graceful shutdown.
1016    Stop {
1017        /// Timeout in milliseconds.
1018        timeout_ms: u64,
1019    },
1020}
1021
1022/// Messages from workload VM to host.
1023#[derive(Debug, Clone, Serialize, Deserialize)]
1024#[serde(tag = "type", rename_all = "snake_case")]
1025pub enum GuestMessage {
1026    /// Authentication successful.
1027    AuthOk,
1028
1029    /// Authentication failed.
1030    AuthFailed,
1031
1032    /// VM is ready to receive commands.
1033    Ready,
1034
1035    /// Command started.
1036    Started {
1037        /// Request ID.
1038        request_id: u64,
1039    },
1040
1041    /// Stdout data from command.
1042    Stdout {
1043        /// Request ID.
1044        request_id: u64,
1045        /// Output data.
1046        #[serde(with = "base64_bytes")]
1047        data: Vec<u8>,
1048        /// Whether output was truncated.
1049        truncated: bool,
1050    },
1051
1052    /// Stderr data from command.
1053    Stderr {
1054        /// Request ID.
1055        request_id: u64,
1056        /// Output data.
1057        #[serde(with = "base64_bytes")]
1058        data: Vec<u8>,
1059        /// Whether output was truncated.
1060        truncated: bool,
1061    },
1062
1063    /// Command exited.
1064    Exit {
1065        /// Request ID.
1066        request_id: u64,
1067        /// Exit code.
1068        code: i32,
1069        /// Exit reason.
1070        reason: String,
1071    },
1072
1073    /// Error occurred.
1074    Error {
1075        /// Request ID (if applicable).
1076        request_id: Option<u64>,
1077        /// Error message.
1078        message: String,
1079    },
1080}
1081
1082// ============================================================================
1083// Wire Format Helpers
1084// ============================================================================
1085
1086/// Envelope that wraps any message with an optional trace ID for correlation.
1087///
1088/// On the wire, the trace_id is flattened into the JSON alongside the message
1089/// fields: `{"trace_id":"abc123","method":"ping"}`.
1090#[derive(Debug, Clone, Serialize, Deserialize)]
1091pub struct Envelope<T> {
1092    /// Trace ID for correlating host API requests to agent operations.
1093    #[serde(skip_serializing_if = "Option::is_none", default)]
1094    pub trace_id: Option<String>,
1095    /// The wrapped message.
1096    #[serde(flatten)]
1097    pub body: T,
1098}
1099
1100impl<T> Envelope<T> {
1101    /// Create an envelope with no trace ID.
1102    pub fn new(body: T) -> Self {
1103        Self {
1104            trace_id: None,
1105            body,
1106        }
1107    }
1108
1109    /// Create an envelope with an optional trace ID.
1110    pub fn with_trace_id(body: T, trace_id: Option<String>) -> Self {
1111        Self { trace_id, body }
1112    }
1113}
1114
1115/// Encode a message to wire format (length-prefixed JSON).
1116pub fn encode_message<T: Serialize>(msg: &T) -> Result<Vec<u8>, serde_json::Error> {
1117    let json = serde_json::to_vec(msg)?;
1118    let len = json.len() as u32;
1119
1120    let mut buf = Vec::with_capacity(4 + json.len());
1121    buf.extend_from_slice(&len.to_be_bytes());
1122    buf.extend_from_slice(&json);
1123
1124    Ok(buf)
1125}
1126
1127/// Decode a message from wire format.
1128pub fn decode_message<T: for<'de> Deserialize<'de>>(data: &[u8]) -> Result<T, DecodeError> {
1129    if data.len() < 4 {
1130        return Err(DecodeError::TooShort);
1131    }
1132
1133    let len = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
1134
1135    if len > MAX_FRAME_SIZE as usize {
1136        return Err(DecodeError::TooLarge(len));
1137    }
1138
1139    if data.len() < 4 + len {
1140        return Err(DecodeError::Incomplete {
1141            expected: len,
1142            got: data.len() - 4,
1143        });
1144    }
1145
1146    serde_json::from_slice(&data[4..4 + len]).map_err(DecodeError::Json)
1147}
1148
1149/// Error decoding a wire message.
1150#[derive(Debug)]
1151pub enum DecodeError {
1152    /// Data too short to contain length header.
1153    TooShort,
1154    /// Frame size exceeds maximum.
1155    TooLarge(usize),
1156    /// Incomplete frame.
1157    Incomplete {
1158        /// Expected length.
1159        expected: usize,
1160        /// Actual length.
1161        got: usize,
1162    },
1163    /// JSON parse error.
1164    Json(serde_json::Error),
1165}
1166
1167impl std::fmt::Display for DecodeError {
1168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1169        match self {
1170            DecodeError::TooShort => write!(f, "data too short for length header"),
1171            DecodeError::TooLarge(size) => write!(f, "frame too large: {} bytes", size),
1172            DecodeError::Incomplete { expected, got } => {
1173                write!(
1174                    f,
1175                    "incomplete frame: expected {} bytes, got {}",
1176                    expected, got
1177                )
1178            }
1179            DecodeError::Json(e) => write!(f, "JSON decode error: {}", e),
1180        }
1181    }
1182}
1183
1184impl std::error::Error for DecodeError {}
1185
1186#[cfg(test)]
1187mod tests {
1188    use super::*;
1189
1190    #[test]
1191    fn test_encode_decode_roundtrip() {
1192        let req = AgentRequest::Pull {
1193            image: "alpine:latest".to_string(),
1194            oci_platform: Some("linux/arm64".to_string()),
1195            auth: None,
1196            proxy: None,
1197            no_proxy: None,
1198        };
1199
1200        let encoded = encode_message(&req).unwrap();
1201        let decoded: AgentRequest = decode_message(&encoded).unwrap();
1202
1203        let AgentRequest::Pull {
1204            image,
1205            oci_platform,
1206            auth,
1207            proxy,
1208            no_proxy,
1209        } = decoded
1210        else {
1211            panic!("expected Pull variant, got {:?}", decoded);
1212        };
1213        assert_eq!(image, "alpine:latest");
1214        assert_eq!(oci_platform, Some("linux/arm64".to_string()));
1215        assert!(auth.is_none());
1216        assert!(proxy.is_none());
1217        assert!(no_proxy.is_none());
1218    }
1219
1220    #[test]
1221    fn test_encode_decode_with_auth() {
1222        let req = AgentRequest::Pull {
1223            image: "ghcr.io/owner/repo:latest".to_string(),
1224            oci_platform: None,
1225            auth: Some(RegistryAuth {
1226                username: "testuser".to_string(),
1227                password: "testpass".to_string(),
1228            }),
1229            proxy: None,
1230            no_proxy: None,
1231        };
1232
1233        let encoded = encode_message(&req).unwrap();
1234        let decoded: AgentRequest = decode_message(&encoded).unwrap();
1235
1236        let AgentRequest::Pull {
1237            image,
1238            oci_platform,
1239            auth,
1240            proxy: _,
1241            no_proxy: _,
1242        } = decoded
1243        else {
1244            panic!("expected Pull variant, got {:?}", decoded);
1245        };
1246        assert_eq!(image, "ghcr.io/owner/repo:latest");
1247        assert!(oci_platform.is_none());
1248        let auth = auth.expect("auth should be Some");
1249        assert_eq!(auth.username, "testuser");
1250        assert_eq!(auth.password, "testpass");
1251    }
1252
1253    #[test]
1254    fn test_encode_decode_with_proxy() {
1255        let req = AgentRequest::Pull {
1256            image: "alpine:latest".to_string(),
1257            oci_platform: None,
1258            auth: None,
1259            proxy: Some("http://192.168.127.254:3128".to_string()),
1260            no_proxy: Some("127.0.0.1,localhost,.internal".to_string()),
1261        };
1262
1263        let encoded = encode_message(&req).unwrap();
1264        let decoded: AgentRequest = decode_message(&encoded).unwrap();
1265
1266        let AgentRequest::Pull {
1267            proxy, no_proxy, ..
1268        } = decoded
1269        else {
1270            panic!("expected Pull variant, got {:?}", decoded);
1271        };
1272        assert_eq!(proxy.as_deref(), Some("http://192.168.127.254:3128"));
1273        assert_eq!(no_proxy.as_deref(), Some("127.0.0.1,localhost,.internal"));
1274    }
1275
1276    #[test]
1277    fn test_decode_too_short() {
1278        let data = [0u8; 2];
1279        let result: Result<AgentRequest, _> = decode_message(&data);
1280        assert!(matches!(result, Err(DecodeError::TooShort)));
1281    }
1282
1283    #[test]
1284    fn test_decode_incomplete() {
1285        let mut data = vec![0, 0, 0, 100]; // claims 100 bytes
1286        data.extend_from_slice(b"{}"); // only 2 bytes of payload
1287        let result: Result<AgentRequest, _> = decode_message(&data);
1288        assert!(matches!(result, Err(DecodeError::Incomplete { .. })));
1289    }
1290
1291    #[test]
1292    fn test_agent_request_serialization() {
1293        let req = AgentRequest::Ping;
1294        let json = serde_json::to_string(&req).unwrap();
1295        assert!(json.contains("ping"));
1296
1297        let req = AgentRequest::PrepareOverlay {
1298            image: "ubuntu:22.04".to_string(),
1299            workload_id: "wl-123".to_string(),
1300        };
1301        let json = serde_json::to_string(&req).unwrap();
1302        assert!(json.contains("prepare_overlay"));
1303    }
1304
1305    #[test]
1306    fn test_agent_response_serialization() {
1307        let resp = AgentResponse::Pong {
1308            version: PROTOCOL_VERSION,
1309        };
1310        let json = serde_json::to_string(&resp).unwrap();
1311        assert!(json.contains("pong"));
1312
1313        let resp = AgentResponse::Progress {
1314            message: "Pulling layer 1/3".to_string(),
1315            percent: Some(33),
1316            layer: Some("sha256:abc123".to_string()),
1317        };
1318        let json = serde_json::to_string(&resp).unwrap();
1319        assert!(json.contains("progress"));
1320    }
1321
1322    #[test]
1323    fn file_write_begin_roundtrips() {
1324        let req = AgentRequest::FileWriteBegin {
1325            path: "/tmp/target".into(),
1326            mode: Some(0o600),
1327            total_size: 123_456_789,
1328        };
1329        let bytes = encode_message(&req).unwrap();
1330        let back: AgentRequest = decode_message(&bytes).unwrap();
1331        match back {
1332            AgentRequest::FileWriteBegin {
1333                path,
1334                mode,
1335                total_size,
1336            } => {
1337                assert_eq!(path, "/tmp/target");
1338                assert_eq!(mode, Some(0o600));
1339                assert_eq!(total_size, 123_456_789);
1340            }
1341            _ => panic!("wrong variant"),
1342        }
1343    }
1344
1345    #[test]
1346    fn file_write_chunk_roundtrips_binary_data() {
1347        // Binary data (bytes outside UTF-8) must survive the base64
1348        // trip intact. If the encoding ever silently lossifies, this
1349        // fires.
1350        let payload: Vec<u8> = (0u8..=255).collect();
1351        let req = AgentRequest::FileWriteChunk {
1352            data: payload.clone(),
1353            done: true,
1354        };
1355        let bytes = encode_message(&req).unwrap();
1356        let back: AgentRequest = decode_message(&bytes).unwrap();
1357        match back {
1358            AgentRequest::FileWriteChunk { data, done } => {
1359                assert_eq!(data, payload);
1360                assert!(done);
1361            }
1362            _ => panic!("wrong variant"),
1363        }
1364    }
1365
1366    #[test]
1367    fn file_write_size_constants_are_frame_safe() {
1368        // Sanity: a single streaming chunk at FILE_WRITE_CHUNK_SIZE
1369        // must fit inside MAX_FRAME_SIZE after base64 (+ ~33%) and
1370        // JSON overhead. If anyone bumps CHUNK_SIZE past the limit,
1371        // this test fires before production does.
1372        let chunk_bytes = FILE_WRITE_CHUNK_SIZE as u64;
1373        let base64_bytes = chunk_bytes.div_ceil(3) * 4; // ceil(n/3)*4
1374        let json_overhead = 256u64; // method tag, done bool, quotes
1375        let total = base64_bytes + json_overhead;
1376        assert!(
1377            total < MAX_FRAME_SIZE as u64,
1378            "FILE_WRITE_CHUNK_SIZE of {} bytes would produce a frame \
1379             of ~{} bytes which exceeds MAX_FRAME_SIZE of {}",
1380            chunk_bytes,
1381            total,
1382            MAX_FRAME_SIZE
1383        );
1384    }
1385
1386    #[test]
1387    fn test_ports_constants() {
1388        assert_eq!(ports::WORKLOAD_CONTROL, 5000);
1389        assert_eq!(ports::WORKLOAD_LOGS, 5001);
1390        assert_eq!(ports::AGENT_CONTROL, 6000);
1391        assert_eq!(ports::SSH_AGENT, 6001);
1392    }
1393
1394    #[test]
1395    fn test_cid_constants() {
1396        assert_eq!(cid::HOST, 2);
1397        assert_eq!(cid::GUEST, 3);
1398    }
1399
1400    #[test]
1401    fn test_envelope_serialization_with_trace_id() {
1402        let req = AgentRequest::Ping;
1403        let envelope = Envelope::with_trace_id(&req, Some("abc123".to_string()));
1404        let json = serde_json::to_string(&envelope).unwrap();
1405
1406        // trace_id should be flattened alongside the method tag
1407        assert!(json.contains("\"trace_id\":\"abc123\""));
1408        assert!(json.contains("\"method\":\"ping\""));
1409
1410        // Deserialize back — Envelope<AgentRequest> with flatten
1411        let parsed: Envelope<AgentRequest> = serde_json::from_str(&json).unwrap();
1412        assert_eq!(parsed.trace_id.as_deref(), Some("abc123"));
1413        assert!(matches!(parsed.body, AgentRequest::Ping));
1414    }
1415
1416    #[test]
1417    fn test_envelope_without_trace_id() {
1418        let req = AgentRequest::Ping;
1419        let envelope = Envelope::new(&req);
1420        let json = serde_json::to_string(&envelope).unwrap();
1421
1422        // No trace_id field (skip_serializing_if = None)
1423        assert!(!json.contains("trace_id"));
1424        assert!(json.contains("\"method\":\"ping\""));
1425    }
1426
1427    #[test]
1428    fn test_envelope_backward_compat_bare_request() {
1429        // A bare AgentRequest (no Envelope) should fail to parse as Envelope
1430        // but succeed as bare AgentRequest — this is the agent's fallback path
1431        let bare_json = r#"{"method":"ping"}"#;
1432
1433        // Envelope parse should fail (no body field to flatten into)
1434        // Actually with flatten, this may work — let's verify
1435        let envelope_result = serde_json::from_str::<Envelope<AgentRequest>>(bare_json);
1436        let bare_result = serde_json::from_str::<AgentRequest>(bare_json);
1437
1438        // At least one must succeed for backward compat
1439        assert!(
1440            envelope_result.is_ok() || bare_result.is_ok(),
1441            "Neither Envelope nor bare parse succeeded"
1442        );
1443
1444        // Bare parse must always work
1445        assert!(bare_result.is_ok());
1446        assert!(matches!(bare_result.unwrap(), AgentRequest::Ping));
1447
1448        // If Envelope works, trace_id should be None
1449        if let Ok(env) = envelope_result {
1450            assert!(env.trace_id.is_none());
1451        }
1452    }
1453}