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