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}