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