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