Skip to main content

a3s_box_core/
config.rs

1use crate::network::NetworkMode;
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5/// Execution isolation selected for a box.
6///
7/// MicroVM remains the implicit default. Host sandbox execution must always be
8/// selected explicitly by the caller and never acts as an automatic fallback.
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum ExecutionIsolation {
12    /// Hardware-backed MicroVM isolation.
13    #[default]
14    Microvm,
15    /// Shared-kernel OCI sandbox isolation.
16    Sandbox,
17}
18
19impl ExecutionIsolation {
20    /// Whether this request selects the shared-kernel sandbox backend.
21    pub fn is_sandbox(self) -> bool {
22        matches!(self, Self::Sandbox)
23    }
24}
25
26/// TEE (Trusted Execution Environment) configuration.
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
28#[serde(tag = "kind", rename_all = "snake_case")]
29pub enum TeeConfig {
30    /// No TEE (standard VM)
31    #[default]
32    None,
33
34    /// AMD SEV-SNP (Secure Encrypted Virtualization - Secure Nested Paging)
35    SevSnp {
36        /// Workload identifier for attestation
37        workload_id: String,
38        /// CPU generation: "milan" or "genoa"
39        #[serde(default)]
40        generation: SevSnpGeneration,
41        /// Enable simulation mode (no hardware required, for development)
42        #[serde(default)]
43        simulate: bool,
44    },
45
46    /// Intel TDX (Trust Domain Extensions) — stub, not yet implemented at runtime.
47    Tdx {
48        /// Workload identifier for attestation
49        workload_id: String,
50        /// Enable simulation mode (no hardware required, for development)
51        #[serde(default)]
52        simulate: bool,
53    },
54}
55
56/// AMD SEV-SNP CPU generation.
57#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
58#[serde(rename_all = "lowercase")]
59pub enum SevSnpGeneration {
60    /// AMD EPYC Milan (3rd gen)
61    #[default]
62    Milan,
63    /// AMD EPYC Genoa (4th gen)
64    Genoa,
65}
66
67impl SevSnpGeneration {
68    /// Get the generation as a string for TEE config.
69    pub fn as_str(&self) -> &'static str {
70        match self {
71            SevSnpGeneration::Milan => "milan",
72            SevSnpGeneration::Genoa => "genoa",
73        }
74    }
75}
76
77/// Cache configuration for cold start optimization.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct CacheConfig {
80    /// Enable rootfs and layer caching (default: true)
81    #[serde(default = "default_true")]
82    pub enabled: bool,
83
84    /// Cache directory (default: ~/.a3s/cache)
85    pub cache_dir: Option<PathBuf>,
86
87    /// Maximum number of cached rootfs entries (default: 10)
88    #[serde(default = "default_max_rootfs_entries")]
89    pub max_rootfs_entries: usize,
90
91    /// Maximum total cache size in bytes (default: 10 GB)
92    #[serde(default = "default_max_cache_bytes")]
93    pub max_cache_bytes: u64,
94}
95
96fn default_true() -> bool {
97    true
98}
99
100fn default_max_rootfs_entries() -> usize {
101    10
102}
103
104fn default_max_cache_bytes() -> u64 {
105    10 * 1024 * 1024 * 1024 // 10 GB
106}
107
108impl Default for CacheConfig {
109    fn default() -> Self {
110        Self {
111            enabled: true,
112            cache_dir: None,
113            max_rootfs_entries: 10,
114            max_cache_bytes: 10 * 1024 * 1024 * 1024,
115        }
116    }
117}
118
119/// Warm pool configuration for pre-booted VMs.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct PoolConfig {
122    /// Enable warm pool (default: false)
123    #[serde(default)]
124    pub enabled: bool,
125
126    /// Minimum number of pre-warmed idle VMs to maintain
127    #[serde(default = "default_min_idle")]
128    pub min_idle: usize,
129
130    /// Maximum number of VMs in the pool (idle + in-use)
131    #[serde(default = "default_max_pool_size")]
132    pub max_size: usize,
133
134    /// Maximum number of VM boots that may run concurrently during pool fill.
135    /// A bounded value prevents a large warm pool from exhausting host CPU and
136    /// memory while retaining parallel startup for independent VMs.
137    #[serde(default = "default_max_concurrent_boots")]
138    pub max_concurrent_boots: usize,
139
140    /// Time-to-live for idle VMs in seconds (0 = unlimited)
141    #[serde(default = "default_idle_ttl")]
142    pub idle_ttl_secs: u64,
143
144    /// Autoscaling policy for dynamic min_idle adjustment
145    #[serde(default)]
146    pub scaling: ScalingPolicy,
147
148    /// Fill the pool by snapshot-fork instead of cold boot: boot ONE template VM
149    /// once, snapshot it, then replenish every other slot by restoring that snapshot
150    /// (MAP_PRIVATE CoW) — turning per-VM fill from a full cold boot (~1.7s) into a
151    /// restore (~tens of ms). All same-image pool VMs share one RAM image.
152    #[serde(default)]
153    pub snapshot_fork: bool,
154}
155
156/// Autoscaling policy for dynamic warm pool sizing.
157///
158/// When enabled, the pool monitors acquire hit/miss rates over a sliding
159/// window and adjusts `min_idle` up or down to match demand pressure.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct ScalingPolicy {
162    /// Enable autoscaling (default: false)
163    #[serde(default)]
164    pub enabled: bool,
165
166    /// Miss rate threshold to trigger scale-up (default: 0.3 = 30%)
167    #[serde(default = "default_scale_up_threshold")]
168    pub scale_up_threshold: f64,
169
170    /// Miss rate threshold to trigger scale-down (default: 0.05 = 5%)
171    #[serde(default = "default_scale_down_threshold")]
172    pub scale_down_threshold: f64,
173
174    /// Upper bound for dynamic min_idle (default: 0 = use max_size)
175    #[serde(default)]
176    pub max_min_idle: usize,
177
178    /// Seconds between scaling decisions (default: 60)
179    #[serde(default = "default_cooldown_secs")]
180    pub cooldown_secs: u64,
181
182    /// Observation window for miss rate calculation in seconds (default: 120)
183    #[serde(default = "default_window_secs")]
184    pub window_secs: u64,
185}
186
187fn default_scale_up_threshold() -> f64 {
188    0.3
189}
190
191fn default_scale_down_threshold() -> f64 {
192    0.05
193}
194
195fn default_cooldown_secs() -> u64 {
196    60
197}
198
199fn default_window_secs() -> u64 {
200    120
201}
202
203impl Default for ScalingPolicy {
204    fn default() -> Self {
205        Self {
206            enabled: false,
207            scale_up_threshold: 0.3,
208            scale_down_threshold: 0.05,
209            max_min_idle: 0,
210            cooldown_secs: 60,
211            window_secs: 120,
212        }
213    }
214}
215
216fn default_min_idle() -> usize {
217    1
218}
219
220fn default_max_pool_size() -> usize {
221    5
222}
223
224fn default_max_concurrent_boots() -> usize {
225    2
226}
227
228fn default_idle_ttl() -> u64 {
229    300 // 5 minutes
230}
231
232impl Default for PoolConfig {
233    fn default() -> Self {
234        Self {
235            enabled: false,
236            min_idle: 1,
237            max_size: 5,
238            max_concurrent_boots: 2,
239            idle_ttl_secs: 300,
240            scaling: ScalingPolicy::default(),
241            snapshot_fork: false,
242        }
243    }
244}
245
246/// Resource limits for a box instance.
247///
248/// Tier 1 limits (rlimits, cpuset) work on all platforms.
249/// Tier 2 limits (cgroup-based) are Linux-only and best-effort.
250#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
251pub struct ResourceLimits {
252    /// PID limit inside the guest (--pids-limit).
253    /// Maps to RLIMIT_NPROC in guest rlimits.
254    #[serde(default)]
255    pub pids_limit: Option<u64>,
256
257    /// CPU pinning: comma-separated CPU IDs (--cpuset-cpus "0,1,3").
258    /// Applied via sched_setaffinity() on the shim process (Linux only).
259    #[serde(default)]
260    pub cpuset_cpus: Option<String>,
261
262    /// Custom rlimits (--ulimit), format: "RESOURCE=SOFT:HARD".
263    #[serde(default)]
264    pub ulimits: Vec<String>,
265
266    /// CPU shares (--cpu-shares), relative weight 2-262144.
267    /// Applied via cgroup v2 cpu.weight (Linux only).
268    #[serde(default)]
269    pub cpu_shares: Option<u64>,
270
271    /// CPU quota in microseconds per --cpu-period (--cpu-quota).
272    /// Applied via cgroup v2 cpu.max (Linux only).
273    #[serde(default)]
274    pub cpu_quota: Option<i64>,
275
276    /// CPU period in microseconds (--cpu-period, default 100000).
277    /// Applied via cgroup v2 cpu.max (Linux only).
278    #[serde(default)]
279    pub cpu_period: Option<u64>,
280
281    /// Memory reservation/soft limit in bytes (--memory-reservation).
282    /// Applied via cgroup v2 memory.low (Linux only).
283    #[serde(default)]
284    pub memory_reservation: Option<u64>,
285
286    /// Memory+swap limit in bytes (--memory-swap, -1 = unlimited).
287    /// Applied via cgroup v2 memory.swap.max (Linux only).
288    #[serde(default)]
289    pub memory_swap: Option<i64>,
290
291    /// Exact hard memory limit for the shared-kernel Sandbox backend.
292    ///
293    /// `None` preserves the CLI's historical MiB-based `resources.memory_mb`
294    /// value. Provider adapters use this field when their public contract is
295    /// byte-granular and must not silently round the requested limit.
296    #[serde(default)]
297    pub sandbox_memory_limit_bytes: Option<u64>,
298}
299
300/// Box configuration
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct BoxConfig {
303    /// Execution isolation. MicroVM is the backwards-compatible default.
304    #[serde(default)]
305    pub isolation: ExecutionIsolation,
306
307    /// OCI image reference (e.g., "nginx:alpine", "ghcr.io/org/app:latest")
308    #[serde(default)]
309    pub image: String,
310
311    /// Workspace directory (mounted to /workspace inside the VM)
312    pub workspace: PathBuf,
313
314    /// Resource limits
315    pub resources: ResourceConfig,
316
317    /// Log level
318    pub log_level: LogLevel,
319
320    /// Enable gRPC debug logging
321    pub debug_grpc: bool,
322
323    /// TEE (Trusted Execution Environment) configuration
324    #[serde(default)]
325    pub tee: TeeConfig,
326
327    /// Command override (replaces OCI CMD when set)
328    #[serde(default)]
329    pub cmd: Vec<String>,
330
331    /// Keep stdin open for the initial container process.
332    ///
333    /// Defaults to false so non-interactive runs do not block forever on prompts.
334    #[serde(default)]
335    pub stdin_open: bool,
336
337    /// Entrypoint override (replaces OCI ENTRYPOINT when set)
338    #[serde(default)]
339    pub entrypoint_override: Option<Vec<String>>,
340
341    /// User override for the initial container process.
342    ///
343    /// Supported runtime format is a numeric `uid` or `uid:gid`.
344    #[serde(default)]
345    pub user: Option<String>,
346
347    /// Working directory override for the initial container process.
348    #[serde(default)]
349    pub workdir: Option<String>,
350
351    /// Hostname to apply inside the box.
352    #[serde(default)]
353    pub hostname: Option<String>,
354
355    /// Extra volume mounts (host_path:guest_path or host_path:guest_path:ro)
356    #[serde(default)]
357    pub volumes: Vec<String>,
358
359    /// virtio-fs cache mode for host directory volumes (`none`, `auto`,
360    /// `always`, or `default`). `None` uses the host environment/default.
361    #[serde(default)]
362    pub virtiofs_cache: Option<String>,
363
364    /// Extra environment variables for the entrypoint
365    #[serde(default)]
366    pub extra_env: Vec<(String, String)>,
367
368    /// Cache configuration for cold start optimization
369    #[serde(default)]
370    pub cache: CacheConfig,
371
372    /// Warm pool configuration for pre-booted VMs
373    #[serde(default)]
374    pub pool: PoolConfig,
375
376    /// Boot the VM IDLE — do not spawn the container main at boot; instead the
377    /// main is started later by a `spawn-main` control frame. Used by the pool so a
378    /// pre-warmed sandbox runs a per-request command as its real main, with full box
379    /// semantics (exit code + json-file console logs) and no cold boot.
380    #[serde(default)]
381    pub deferred_main: bool,
382
383    /// Mark guest memory KSM-mergeable so the host kernel dedups identical pages
384    /// across same-image VMs (Linux 6.4+; needs /sys/kernel/mm/ksm/run=1 on the
385    /// host). Most valuable for pools of same-image sandboxes.
386    #[serde(default)]
387    pub ksm: bool,
388
389    /// Snapshot-fork (per-VM): file-backed guest RAM path for a snapshot TEMPLATE
390    /// (paired with `snapshot_sock`), or the RAM file to MAP_PRIVATE CoW-restore
391    /// from (paired with `restore_from`).
392    #[serde(default)]
393    pub snapshot_mem_file: Option<String>,
394
395    /// Snapshot-fork (per-VM): unix socket on which libkrun serves snapshot
396    /// requests for a template VM.
397    #[serde(default)]
398    pub snapshot_sock: Option<String>,
399
400    /// Snapshot-fork (per-VM): state file to RESTORE from — this VM resumes the
401    /// snapshotted template (CoW of `snapshot_mem_file`) instead of cold-booting.
402    /// The per-VM seam that lets one process (pool / fork daemon) fork many VMs.
403    #[serde(default)]
404    pub restore_from: Option<String>,
405
406    /// Port mappings: "host_port:guest_port" (e.g., "8080:80")
407    /// Maps host ports to guest ports via TSI (Transparent Socket Impersonation).
408    #[serde(default)]
409    pub port_map: Vec<String>,
410
411    /// Custom DNS servers (e.g., "1.1.1.1").
412    /// If empty, reads from host /etc/resolv.conf, falling back to 8.8.8.8.
413    #[serde(default)]
414    pub dns: Vec<String>,
415
416    /// Static host-to-IP mappings for `/etc/hosts` (`HOST:IP`).
417    #[serde(default)]
418    pub add_hosts: Vec<String>,
419
420    /// Network mode: TSI (default), bridge (passt-based), or none.
421    #[serde(default)]
422    pub network: NetworkMode,
423
424    /// tmpfs mounts (ephemeral in-guest filesystems).
425    /// Format: "/path" or "/path:size=100m"
426    #[serde(default)]
427    pub tmpfs: Vec<String>,
428
429    /// Resource limits (PID limits, CPU pinning, ulimits, cgroup controls).
430    #[serde(default)]
431    pub resource_limits: ResourceLimits,
432
433    /// Linux capabilities to add (e.g., "NET_ADMIN", "SYS_PTRACE")
434    #[serde(default)]
435    pub cap_add: Vec<String>,
436
437    /// Linux capabilities to drop (e.g., "ALL", "NET_RAW")
438    #[serde(default)]
439    pub cap_drop: Vec<String>,
440
441    /// Security options (e.g., "seccomp=unconfined", "no-new-privileges")
442    #[serde(default)]
443    pub security_opt: Vec<String>,
444
445    /// Kernel sysctls (name → value) applied in the guest at boot.
446    ///
447    /// Pod-level sysctls from the CRI `PodSandboxConfig`; the guest writes each
448    /// to `/proc/sys/<name with '.' as '/'>` once the VM is up.
449    #[serde(default)]
450    pub sysctls: Vec<(String, String)>,
451
452    /// Run in privileged mode (disables all security restrictions)
453    #[serde(default)]
454    pub privileged: bool,
455
456    /// Mount the container rootfs as read-only.
457    ///
458    /// Volume mounts (-v host:guest) remain writable by default.
459    /// Requires guest init to be present in the rootfs image.
460    #[serde(default)]
461    pub read_only: bool,
462
463    /// Optional sidecar process to run alongside the main container inside the VM.
464    ///
465    /// The sidecar is launched before the main container entrypoint and runs
466    /// as a co-process inside the same MicroVM. Intended for security proxies
467    /// such as SafeClaw that intercept and classify agent traffic.
468    #[serde(default)]
469    pub sidecar: Option<SidecarConfig>,
470
471    /// Preserve the box filesystem across stop/start cycles.
472    ///
473    /// When true, the overlay upper layer (or copy rootfs) is kept on disk
474    /// after the box stops and reused on the next start. Changes made inside
475    /// the box persist between restarts, similar to a traditional VM.
476    ///
477    /// When false (default), the writable layer is wiped on every stop,
478    /// giving a clean slate on each start.
479    #[serde(default)]
480    pub persistent: bool,
481}
482
483impl Default for BoxConfig {
484    fn default() -> Self {
485        Self {
486            isolation: ExecutionIsolation::default(),
487            image: String::new(),
488            // Empty path signals the runtime to create a per-box workspace
489            // under ~/.a3s/boxes/<box_id>/workspace/ at boot time.
490            workspace: PathBuf::new(),
491            resources: ResourceConfig::default(),
492            log_level: LogLevel::Info,
493            debug_grpc: false,
494            tee: TeeConfig::default(),
495            cmd: vec![],
496            stdin_open: false,
497            entrypoint_override: None,
498            user: None,
499            workdir: None,
500            hostname: None,
501            volumes: vec![],
502            virtiofs_cache: None,
503            extra_env: vec![],
504            cache: CacheConfig::default(),
505            pool: PoolConfig::default(),
506            deferred_main: false,
507            ksm: false,
508            snapshot_mem_file: None,
509            snapshot_sock: None,
510            restore_from: None,
511            port_map: vec![],
512            dns: vec![],
513            add_hosts: vec![],
514            network: NetworkMode::default(),
515            tmpfs: vec![],
516            resource_limits: ResourceLimits::default(),
517            cap_add: vec![],
518            cap_drop: vec![],
519            security_opt: vec![],
520            sysctls: vec![],
521            privileged: false,
522            read_only: false,
523            sidecar: None,
524            persistent: false,
525        }
526    }
527}
528
529/// Sidecar process configuration.
530///
531/// A sidecar runs as a co-process inside the same MicroVM alongside the main
532/// container. It is launched before the main entrypoint and communicates with
533/// the host via a dedicated vsock port.
534///
535/// Primary use case: SafeClaw security proxy that intercepts and classifies
536/// agent traffic before it reaches the LLM.
537///
538/// # Data flow
539///
540/// ```text
541/// Agent → SafeClaw (vsock 4092) → classified/sanitized → LLM
542/// ```
543#[derive(Debug, Clone, Serialize, Deserialize)]
544pub struct SidecarConfig {
545    /// OCI image reference for the sidecar (e.g., "ghcr.io/a3s-lab/safeclaw:latest")
546    pub image: String,
547
548    /// Vsock port the sidecar listens on for host-side control (default: 4092)
549    #[serde(default = "default_sidecar_vsock_port")]
550    pub vsock_port: u32,
551
552    /// Extra environment variables for the sidecar process
553    #[serde(default)]
554    pub env: Vec<(String, String)>,
555}
556
557fn default_sidecar_vsock_port() -> u32 {
558    4092
559}
560
561/// Default virtual CPU count for the current host backend.
562///
563/// The Windows WHPX backend currently supports a reliable single-vCPU boot
564/// path. Linux and macOS retain the existing two-vCPU default.
565pub const DEFAULT_VCPUS: u32 = if cfg!(target_os = "windows") { 1 } else { 2 };
566
567/// Maximum virtual CPU count accepted by the libkrun API.
568pub const MAX_VCPUS: u32 = u8::MAX as u32;
569
570/// Validate a virtual CPU count against the current host backend.
571pub fn validate_vcpu_count(vcpus: u32) -> std::result::Result<(), String> {
572    if vcpus == 0 {
573        return Err("virtual CPU count must be at least 1".to_string());
574    }
575    if vcpus > MAX_VCPUS {
576        return Err(format!(
577            "virtual CPU count {vcpus} exceeds the maximum of {MAX_VCPUS}"
578        ));
579    }
580
581    #[cfg(target_os = "windows")]
582    if vcpus != 1 {
583        return Err(format!(
584            "the Windows WHPX backend currently supports exactly 1 virtual CPU; requested {vcpus}"
585        ));
586    }
587
588    Ok(())
589}
590
591impl Default for SidecarConfig {
592    fn default() -> Self {
593        Self {
594            image: String::new(),
595            vsock_port: default_sidecar_vsock_port(),
596            env: vec![],
597        }
598    }
599}
600
601/// Resource configuration
602#[derive(Debug, Clone, Serialize, Deserialize)]
603pub struct ResourceConfig {
604    /// Number of virtual CPUs
605    pub vcpus: u32,
606
607    /// Memory in MB
608    pub memory_mb: u32,
609
610    /// Disk space in MB
611    pub disk_mb: u32,
612
613    /// Optional writable-layer quota in bytes.
614    ///
615    /// This is distinct from `disk_mb`: the latter describes the logical
616    /// capacity of a MicroVM root disk, while this bound applies to the
617    /// mutable overlay layer used by the Linux Sandbox provider. Providers
618    /// that cannot enforce the byte-precise limit must reject the request
619    /// rather than silently treating it as an unbounded filesystem.
620    #[serde(default, skip_serializing_if = "Option::is_none")]
621    pub ephemeral_storage_bytes: Option<u64>,
622
623    /// Box lifetime timeout in seconds (0 = unlimited)
624    pub timeout: u64,
625}
626
627impl Default for ResourceConfig {
628    fn default() -> Self {
629        Self {
630            vcpus: DEFAULT_VCPUS,
631            memory_mb: 1024,
632            disk_mb: 4096,
633            ephemeral_storage_bytes: None,
634            timeout: 3600, // 1 hour
635        }
636    }
637}
638
639/// Log level
640#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
641pub enum LogLevel {
642    Debug,
643    Info,
644    Warn,
645    Error,
646}
647
648impl From<LogLevel> for tracing::Level {
649    fn from(level: LogLevel) -> Self {
650        match level {
651            LogLevel::Debug => tracing::Level::DEBUG,
652            LogLevel::Info => tracing::Level::INFO,
653            LogLevel::Warn => tracing::Level::WARN,
654            LogLevel::Error => tracing::Level::ERROR,
655        }
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    #[test]
664    fn test_box_config_default() {
665        let config = BoxConfig::default();
666
667        assert!(config.image.is_empty());
668        // Empty workspace signals the runtime to use a per-box directory at boot time.
669        assert!(config.workspace.as_os_str().is_empty());
670        assert_eq!(config.resources.vcpus, DEFAULT_VCPUS);
671        assert!(!config.debug_grpc);
672        assert!(!config.read_only);
673        assert!(config.user.is_none());
674        assert!(config.workdir.is_none());
675        assert!(config.hostname.is_none());
676        assert!(config.add_hosts.is_empty());
677    }
678
679    #[test]
680    fn test_box_config_read_only_default_false() {
681        let config = BoxConfig::default();
682        assert!(!config.read_only);
683    }
684
685    #[test]
686    fn test_box_config_read_only_serde() {
687        // read_only defaults to false when absent from JSON
688        let json = r#"{"image":"test","workspace":"","resources":{"vcpus":2,"memory_mb":512,"disk_mb":4096,"timeout":3600},"log_level":"Info","debug_grpc":false}"#;
689        let config: BoxConfig = serde_json::from_str(json).unwrap();
690        assert!(!config.read_only);
691
692        // read_only=true roundtrips correctly
693        let config = BoxConfig {
694            read_only: true,
695            ..Default::default()
696        };
697        let json = serde_json::to_string(&config).unwrap();
698        let deserialized: BoxConfig = serde_json::from_str(&json).unwrap();
699        assert!(deserialized.read_only);
700    }
701
702    #[test]
703    fn test_box_config_user_workdir_serde() {
704        let config = BoxConfig {
705            user: Some("1000:1000".to_string()),
706            workdir: Some("/app".to_string()),
707            ..Default::default()
708        };
709
710        let json = serde_json::to_string(&config).unwrap();
711        let parsed: BoxConfig = serde_json::from_str(&json).unwrap();
712
713        assert_eq!(parsed.user.as_deref(), Some("1000:1000"));
714        assert_eq!(parsed.workdir.as_deref(), Some("/app"));
715    }
716
717    #[test]
718    fn test_box_config_hostname_add_hosts_serde() {
719        let config = BoxConfig {
720            hostname: Some("web".to_string()),
721            add_hosts: vec!["db.local:10.88.0.10".to_string()],
722            ..Default::default()
723        };
724
725        let json = serde_json::to_string(&config).unwrap();
726        let parsed: BoxConfig = serde_json::from_str(&json).unwrap();
727
728        assert_eq!(parsed.hostname.as_deref(), Some("web"));
729        assert_eq!(parsed.add_hosts, vec!["db.local:10.88.0.10"]);
730    }
731
732    #[test]
733    fn test_resource_config_default() {
734        let config = ResourceConfig::default();
735
736        assert_eq!(config.vcpus, DEFAULT_VCPUS);
737        assert_eq!(config.memory_mb, 1024);
738        assert_eq!(config.disk_mb, 4096);
739        assert_eq!(config.ephemeral_storage_bytes, None);
740        assert_eq!(config.timeout, 3600);
741    }
742
743    #[test]
744    fn test_validate_vcpu_count() {
745        assert!(validate_vcpu_count(DEFAULT_VCPUS).is_ok());
746        assert!(validate_vcpu_count(0).unwrap_err().contains("at least 1"));
747        assert!(validate_vcpu_count(MAX_VCPUS + 1)
748            .unwrap_err()
749            .contains("maximum"));
750
751        #[cfg(target_os = "windows")]
752        assert!(validate_vcpu_count(2).unwrap_err().contains("WHPX"));
753        #[cfg(not(target_os = "windows"))]
754        assert!(validate_vcpu_count(2).is_ok());
755    }
756
757    #[test]
758    fn test_resource_config_custom() {
759        let config = ResourceConfig {
760            vcpus: 4,
761            memory_mb: 2048,
762            disk_mb: 8192,
763            ephemeral_storage_bytes: None,
764            timeout: 7200,
765        };
766
767        assert_eq!(config.vcpus, 4);
768        assert_eq!(config.memory_mb, 2048);
769        assert_eq!(config.disk_mb, 8192);
770        assert_eq!(config.ephemeral_storage_bytes, None);
771        assert_eq!(config.timeout, 7200);
772    }
773
774    #[test]
775    fn test_log_level_conversion() {
776        assert_eq!(tracing::Level::from(LogLevel::Debug), tracing::Level::DEBUG);
777        assert_eq!(tracing::Level::from(LogLevel::Info), tracing::Level::INFO);
778        assert_eq!(tracing::Level::from(LogLevel::Warn), tracing::Level::WARN);
779        assert_eq!(tracing::Level::from(LogLevel::Error), tracing::Level::ERROR);
780    }
781
782    #[test]
783    fn test_box_config_serialization() {
784        let config = BoxConfig::default();
785        let json = serde_json::to_string(&config).unwrap();
786
787        assert!(json.contains("workspace"));
788        assert!(json.contains("resources"));
789    }
790
791    #[test]
792    fn test_box_config_deserialization() {
793        let json = r#"{
794            "image": "nginx:alpine",
795            "workspace": "/tmp/workspace",
796            "resources": {
797                "vcpus": 4,
798                "memory_mb": 2048,
799                "disk_mb": 8192,
800                "timeout": 1800
801            },
802            "log_level": "Debug",
803            "debug_grpc": true
804        }"#;
805
806        let config: BoxConfig = serde_json::from_str(json).unwrap();
807        assert_eq!(config.image, "nginx:alpine");
808        assert_eq!(config.workspace.to_str().unwrap(), "/tmp/workspace");
809        assert_eq!(config.resources.vcpus, 4);
810        assert!(config.debug_grpc);
811    }
812
813    #[test]
814    fn test_resource_config_serialization() {
815        let config = ResourceConfig {
816            vcpus: 8,
817            memory_mb: 4096,
818            disk_mb: 16384,
819            ephemeral_storage_bytes: None,
820            timeout: 0,
821        };
822
823        let json = serde_json::to_string(&config).unwrap();
824        let parsed: ResourceConfig = serde_json::from_str(&json).unwrap();
825
826        assert_eq!(parsed.vcpus, 8);
827        assert_eq!(parsed.memory_mb, 4096);
828        assert_eq!(parsed.ephemeral_storage_bytes, None);
829        assert_eq!(parsed.timeout, 0); // Unlimited
830    }
831
832    #[test]
833    fn test_resource_config_ephemeral_storage_roundtrip_and_legacy_shape() {
834        let config = ResourceConfig {
835            ephemeral_storage_bytes: Some(64 * 1024 * 1024),
836            ..ResourceConfig::default()
837        };
838        let json = serde_json::to_string(&config).unwrap();
839        assert!(json.contains("ephemeral_storage_bytes"));
840        let parsed: ResourceConfig = serde_json::from_str(&json).unwrap();
841        assert_eq!(
842            parsed.ephemeral_storage_bytes,
843            config.ephemeral_storage_bytes
844        );
845
846        let legacy = r#"{"vcpus":1,"memory_mb":128,"disk_mb":512,"timeout":0}"#;
847        let parsed: ResourceConfig = serde_json::from_str(legacy).unwrap();
848        assert_eq!(parsed.ephemeral_storage_bytes, None);
849        assert!(!serde_json::to_string(&parsed)
850            .unwrap()
851            .contains("ephemeral_storage_bytes"));
852    }
853
854    #[test]
855    fn test_log_level_serialization() {
856        let levels = vec![
857            LogLevel::Debug,
858            LogLevel::Info,
859            LogLevel::Warn,
860            LogLevel::Error,
861        ];
862
863        for level in levels {
864            let json = serde_json::to_string(&level).unwrap();
865            let parsed: LogLevel = serde_json::from_str(&json).unwrap();
866            assert_eq!(tracing::Level::from(parsed), tracing::Level::from(level));
867        }
868    }
869
870    #[test]
871    fn test_config_clone() {
872        let config = BoxConfig::default();
873        let cloned = config.clone();
874
875        assert_eq!(config.workspace, cloned.workspace);
876        assert_eq!(config.resources.vcpus, cloned.resources.vcpus);
877    }
878
879    #[test]
880    fn test_config_debug() {
881        let config = BoxConfig::default();
882        let debug_str = format!("{:?}", config);
883
884        assert!(debug_str.contains("BoxConfig"));
885        assert!(debug_str.contains("workspace"));
886    }
887
888    #[test]
889    fn test_tee_config_default() {
890        let tee = TeeConfig::default();
891        assert_eq!(tee, TeeConfig::None);
892    }
893
894    #[test]
895    fn test_tee_config_sev_snp() {
896        let tee = TeeConfig::SevSnp {
897            workload_id: "test-agent".to_string(),
898            generation: SevSnpGeneration::Milan,
899            simulate: false,
900        };
901
902        match tee {
903            TeeConfig::SevSnp {
904                workload_id,
905                generation,
906                simulate,
907            } => {
908                assert_eq!(workload_id, "test-agent");
909                assert_eq!(generation, SevSnpGeneration::Milan);
910                assert!(!simulate);
911            }
912            _ => panic!("Expected SevSnp variant"),
913        }
914    }
915
916    #[test]
917    fn test_sev_snp_generation_as_str() {
918        assert_eq!(SevSnpGeneration::Milan.as_str(), "milan");
919        assert_eq!(SevSnpGeneration::Genoa.as_str(), "genoa");
920    }
921
922    #[test]
923    fn test_sev_snp_generation_default() {
924        let gen = SevSnpGeneration::default();
925        assert_eq!(gen, SevSnpGeneration::Milan);
926    }
927
928    #[test]
929    fn test_tee_config_serialization() {
930        let tee = TeeConfig::SevSnp {
931            workload_id: "my-workload".to_string(),
932            generation: SevSnpGeneration::Genoa,
933            simulate: false,
934        };
935
936        let json = serde_json::to_string(&tee).unwrap();
937        let parsed: TeeConfig = serde_json::from_str(&json).unwrap();
938
939        assert_eq!(parsed, tee);
940    }
941
942    #[test]
943    fn test_tee_config_none_serialization() {
944        let tee = TeeConfig::None;
945        let json = serde_json::to_string(&tee).unwrap();
946        let parsed: TeeConfig = serde_json::from_str(&json).unwrap();
947
948        assert_eq!(parsed, TeeConfig::None);
949    }
950
951    #[test]
952    fn test_tee_config_tdx() {
953        let tee = TeeConfig::Tdx {
954            workload_id: "tdx-workload".to_string(),
955            simulate: false,
956        };
957        let json = serde_json::to_string(&tee).unwrap();
958        let parsed: TeeConfig = serde_json::from_str(&json).unwrap();
959        match parsed {
960            TeeConfig::Tdx {
961                workload_id,
962                simulate,
963            } => {
964                assert_eq!(workload_id, "tdx-workload");
965                assert!(!simulate);
966            }
967            _ => panic!("Expected Tdx variant"),
968        }
969    }
970
971    #[test]
972    fn test_tee_config_tdx_simulate() {
973        let tee = TeeConfig::Tdx {
974            workload_id: "test".to_string(),
975            simulate: true,
976        };
977        let json = serde_json::to_string(&tee).unwrap();
978        let parsed: TeeConfig = serde_json::from_str(&json).unwrap();
979        match parsed {
980            TeeConfig::Tdx { simulate, .. } => assert!(simulate),
981            _ => panic!("Expected Tdx variant"),
982        }
983    }
984
985    #[test]
986    fn test_box_config_with_tee() {
987        let config = BoxConfig {
988            tee: TeeConfig::SevSnp {
989                workload_id: "secure-agent".to_string(),
990                generation: SevSnpGeneration::Milan,
991                simulate: false,
992            },
993            ..Default::default()
994        };
995
996        let json = serde_json::to_string(&config).unwrap();
997        let parsed: BoxConfig = serde_json::from_str(&json).unwrap();
998
999        match parsed.tee {
1000            TeeConfig::SevSnp {
1001                workload_id,
1002                generation,
1003                simulate,
1004            } => {
1005                assert_eq!(workload_id, "secure-agent");
1006                assert_eq!(generation, SevSnpGeneration::Milan);
1007                assert!(!simulate);
1008            }
1009            _ => panic!("Expected SevSnp TEE config"),
1010        }
1011    }
1012
1013    #[test]
1014    fn test_box_config_default_has_no_tee() {
1015        let config = BoxConfig::default();
1016        assert_eq!(config.tee, TeeConfig::None);
1017    }
1018
1019    // --- CacheConfig tests ---
1020
1021    #[test]
1022    fn test_cache_config_default() {
1023        let config = CacheConfig::default();
1024        assert!(config.enabled);
1025        assert!(config.cache_dir.is_none());
1026        assert_eq!(config.max_rootfs_entries, 10);
1027        assert_eq!(config.max_cache_bytes, 10 * 1024 * 1024 * 1024);
1028    }
1029
1030    #[test]
1031    fn test_cache_config_serialization() {
1032        let config = CacheConfig {
1033            enabled: false,
1034            cache_dir: Some(PathBuf::from("/tmp/cache")),
1035            max_rootfs_entries: 5,
1036            max_cache_bytes: 1024 * 1024 * 1024,
1037        };
1038
1039        let json = serde_json::to_string(&config).unwrap();
1040        let parsed: CacheConfig = serde_json::from_str(&json).unwrap();
1041
1042        assert!(!parsed.enabled);
1043        assert_eq!(parsed.cache_dir, Some(PathBuf::from("/tmp/cache")));
1044        assert_eq!(parsed.max_rootfs_entries, 5);
1045        assert_eq!(parsed.max_cache_bytes, 1024 * 1024 * 1024);
1046    }
1047
1048    #[test]
1049    fn test_cache_config_deserialization_defaults() {
1050        let json = "{}";
1051        let config: CacheConfig = serde_json::from_str(json).unwrap();
1052
1053        assert!(config.enabled);
1054        assert!(config.cache_dir.is_none());
1055        assert_eq!(config.max_rootfs_entries, 10);
1056        assert_eq!(config.max_cache_bytes, 10 * 1024 * 1024 * 1024);
1057    }
1058
1059    // --- PoolConfig tests ---
1060
1061    #[test]
1062    fn test_pool_config_default() {
1063        let config = PoolConfig::default();
1064        assert!(!config.enabled);
1065        assert_eq!(config.min_idle, 1);
1066        assert_eq!(config.max_size, 5);
1067        assert_eq!(config.max_concurrent_boots, 2);
1068        assert_eq!(config.idle_ttl_secs, 300);
1069    }
1070
1071    #[test]
1072    fn test_pool_config_serialization() {
1073        let config = PoolConfig {
1074            enabled: true,
1075            min_idle: 3,
1076            max_size: 10,
1077            max_concurrent_boots: 4,
1078            idle_ttl_secs: 600,
1079            ..Default::default()
1080        };
1081
1082        let json = serde_json::to_string(&config).unwrap();
1083        let parsed: PoolConfig = serde_json::from_str(&json).unwrap();
1084
1085        assert!(parsed.enabled);
1086        assert_eq!(parsed.min_idle, 3);
1087        assert_eq!(parsed.max_size, 10);
1088        assert_eq!(parsed.max_concurrent_boots, 4);
1089        assert_eq!(parsed.idle_ttl_secs, 600);
1090    }
1091
1092    #[test]
1093    fn test_pool_config_deserialization_defaults() {
1094        let json = "{}";
1095        let config: PoolConfig = serde_json::from_str(json).unwrap();
1096
1097        assert!(!config.enabled);
1098        assert_eq!(config.min_idle, 1);
1099        assert_eq!(config.max_size, 5);
1100        assert_eq!(config.max_concurrent_boots, 2);
1101        assert_eq!(config.idle_ttl_secs, 300);
1102    }
1103
1104    // --- BoxConfig with new fields ---
1105
1106    #[test]
1107    fn test_box_config_default_has_cache_and_pool() {
1108        let config = BoxConfig::default();
1109        assert!(config.cache.enabled);
1110        assert!(!config.pool.enabled);
1111    }
1112
1113    #[test]
1114    fn test_box_config_with_cache_serialization() {
1115        let config = BoxConfig {
1116            cache: CacheConfig {
1117                enabled: false,
1118                cache_dir: Some(PathBuf::from("/custom/cache")),
1119                max_rootfs_entries: 20,
1120                max_cache_bytes: 5 * 1024 * 1024 * 1024,
1121            },
1122            ..Default::default()
1123        };
1124
1125        let json = serde_json::to_string(&config).unwrap();
1126        let parsed: BoxConfig = serde_json::from_str(&json).unwrap();
1127
1128        assert!(!parsed.cache.enabled);
1129        assert_eq!(parsed.cache.cache_dir, Some(PathBuf::from("/custom/cache")));
1130        assert_eq!(parsed.cache.max_rootfs_entries, 20);
1131    }
1132
1133    #[test]
1134    fn test_box_config_with_pool_serialization() {
1135        let config = BoxConfig {
1136            pool: PoolConfig {
1137                enabled: true,
1138                min_idle: 2,
1139                max_size: 8,
1140                idle_ttl_secs: 120,
1141                ..Default::default()
1142            },
1143            ..Default::default()
1144        };
1145
1146        let json = serde_json::to_string(&config).unwrap();
1147        let parsed: BoxConfig = serde_json::from_str(&json).unwrap();
1148
1149        assert!(parsed.pool.enabled);
1150        assert_eq!(parsed.pool.min_idle, 2);
1151        assert_eq!(parsed.pool.max_size, 8);
1152        assert_eq!(parsed.pool.max_concurrent_boots, 2);
1153        assert_eq!(parsed.pool.idle_ttl_secs, 120);
1154    }
1155
1156    #[test]
1157    fn test_box_config_backward_compatible_deserialization() {
1158        // JSON without cache/pool fields should still deserialize with defaults
1159        let json = r#"{
1160            "workspace": "/tmp/workspace",
1161            "resources": {
1162                "vcpus": 2,
1163                "memory_mb": 1024,
1164                "disk_mb": 4096,
1165                "timeout": 3600
1166            },
1167            "log_level": "Info",
1168            "debug_grpc": false
1169        }"#;
1170
1171        let config: BoxConfig = serde_json::from_str(json).unwrap();
1172        assert!(config.cache.enabled);
1173        assert!(!config.pool.enabled);
1174    }
1175
1176    // --- ResourceLimits tests ---
1177
1178    #[test]
1179    fn test_resource_limits_default() {
1180        let limits = ResourceLimits::default();
1181        assert!(limits.pids_limit.is_none());
1182        assert!(limits.cpuset_cpus.is_none());
1183        assert!(limits.ulimits.is_empty());
1184        assert!(limits.cpu_shares.is_none());
1185        assert!(limits.cpu_quota.is_none());
1186        assert!(limits.cpu_period.is_none());
1187        assert!(limits.memory_reservation.is_none());
1188        assert!(limits.memory_swap.is_none());
1189        assert!(limits.sandbox_memory_limit_bytes.is_none());
1190    }
1191
1192    #[test]
1193    fn test_resource_limits_serialization() {
1194        let limits = ResourceLimits {
1195            pids_limit: Some(100),
1196            cpuset_cpus: Some("0,1".to_string()),
1197            ulimits: vec!["nofile=1024:4096".to_string()],
1198            cpu_shares: Some(512),
1199            cpu_quota: Some(50000),
1200            cpu_period: Some(100000),
1201            memory_reservation: Some(256 * 1024 * 1024),
1202            memory_swap: Some(1024 * 1024 * 1024),
1203            sandbox_memory_limit_bytes: Some(256 * 1024 * 1024),
1204        };
1205
1206        let json = serde_json::to_string(&limits).unwrap();
1207        let parsed: ResourceLimits = serde_json::from_str(&json).unwrap();
1208
1209        assert_eq!(parsed.pids_limit, Some(100));
1210        assert_eq!(parsed.cpuset_cpus, Some("0,1".to_string()));
1211        assert_eq!(parsed.ulimits, vec!["nofile=1024:4096"]);
1212        assert_eq!(parsed.cpu_shares, Some(512));
1213        assert_eq!(parsed.cpu_quota, Some(50000));
1214        assert_eq!(parsed.cpu_period, Some(100000));
1215        assert_eq!(parsed.memory_reservation, Some(256 * 1024 * 1024));
1216        assert_eq!(parsed.memory_swap, Some(1024 * 1024 * 1024));
1217        assert_eq!(parsed.sandbox_memory_limit_bytes, Some(256 * 1024 * 1024));
1218    }
1219
1220    #[test]
1221    fn test_resource_limits_deserialization_defaults() {
1222        let json = "{}";
1223        let limits: ResourceLimits = serde_json::from_str(json).unwrap();
1224        assert!(limits.pids_limit.is_none());
1225        assert!(limits.ulimits.is_empty());
1226    }
1227
1228    #[test]
1229    fn test_resource_limits_memory_swap_unlimited() {
1230        let limits = ResourceLimits {
1231            memory_swap: Some(-1),
1232            ..Default::default()
1233        };
1234
1235        let json = serde_json::to_string(&limits).unwrap();
1236        let parsed: ResourceLimits = serde_json::from_str(&json).unwrap();
1237        assert_eq!(parsed.memory_swap, Some(-1));
1238    }
1239
1240    #[test]
1241    fn test_box_config_with_resource_limits() {
1242        let config = BoxConfig {
1243            resource_limits: ResourceLimits {
1244                pids_limit: Some(256),
1245                cpu_shares: Some(1024),
1246                ..Default::default()
1247            },
1248            ..Default::default()
1249        };
1250
1251        let json = serde_json::to_string(&config).unwrap();
1252        let parsed: BoxConfig = serde_json::from_str(&json).unwrap();
1253
1254        assert_eq!(parsed.resource_limits.pids_limit, Some(256));
1255        assert_eq!(parsed.resource_limits.cpu_shares, Some(1024));
1256    }
1257
1258    #[test]
1259    fn test_box_config_backward_compat_no_resource_limits() {
1260        // Old configs without resource_limits should deserialize with defaults
1261        let json = r#"{
1262            "workspace": "/tmp/workspace",
1263            "resources": {
1264                "vcpus": 2,
1265                "memory_mb": 1024,
1266                "disk_mb": 4096,
1267                "timeout": 3600
1268            },
1269            "log_level": "Info",
1270            "debug_grpc": false
1271        }"#;
1272
1273        let config: BoxConfig = serde_json::from_str(json).unwrap();
1274        assert!(config.resource_limits.pids_limit.is_none());
1275        assert!(config.resource_limits.ulimits.is_empty());
1276    }
1277
1278    // ── SidecarConfig tests ───────────────────────────────────────────
1279
1280    #[test]
1281    fn test_sidecar_config_default() {
1282        let s = SidecarConfig::default();
1283        assert!(s.image.is_empty());
1284        assert_eq!(s.vsock_port, 4092);
1285        assert!(s.env.is_empty());
1286    }
1287
1288    #[test]
1289    fn test_sidecar_config_roundtrip() {
1290        let s = SidecarConfig {
1291            image: "ghcr.io/a3s-lab/safeclaw:latest".to_string(),
1292            vsock_port: 4092,
1293            env: vec![
1294                ("LOG_LEVEL".to_string(), "debug".to_string()),
1295                ("MODE".to_string(), "proxy".to_string()),
1296            ],
1297        };
1298        let json = serde_json::to_string(&s).unwrap();
1299        let parsed: SidecarConfig = serde_json::from_str(&json).unwrap();
1300        assert_eq!(parsed.image, "ghcr.io/a3s-lab/safeclaw:latest");
1301        assert_eq!(parsed.vsock_port, 4092);
1302        assert_eq!(parsed.env.len(), 2);
1303        assert_eq!(
1304            parsed.env[0],
1305            ("LOG_LEVEL".to_string(), "debug".to_string())
1306        );
1307    }
1308
1309    #[test]
1310    fn test_sidecar_config_default_vsock_port_from_json() {
1311        let json = r#"{"image":"safeclaw:latest"}"#;
1312        let s: SidecarConfig = serde_json::from_str(json).unwrap();
1313        assert_eq!(s.vsock_port, 4092);
1314        assert!(s.env.is_empty());
1315    }
1316
1317    #[test]
1318    fn test_box_config_default_has_no_sidecar() {
1319        let config = BoxConfig::default();
1320        assert!(config.sidecar.is_none());
1321    }
1322
1323    #[test]
1324    fn test_box_config_with_sidecar_roundtrip() {
1325        let config = BoxConfig {
1326            sidecar: Some(SidecarConfig {
1327                image: "safeclaw:latest".to_string(),
1328                vsock_port: 4092,
1329                env: vec![],
1330            }),
1331            ..Default::default()
1332        };
1333        let json = serde_json::to_string(&config).unwrap();
1334        let parsed: BoxConfig = serde_json::from_str(&json).unwrap();
1335        let sidecar = parsed.sidecar.unwrap();
1336        assert_eq!(sidecar.image, "safeclaw:latest");
1337        assert_eq!(sidecar.vsock_port, 4092);
1338    }
1339
1340    #[test]
1341    fn test_box_config_without_sidecar_deserializes_as_none() {
1342        // Old configs without sidecar field should deserialize with sidecar=None
1343        let config = BoxConfig::default();
1344        let json = serde_json::to_string(&config).unwrap();
1345        let parsed: BoxConfig = serde_json::from_str(&json).unwrap();
1346        assert!(parsed.sidecar.is_none());
1347    }
1348}