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