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