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