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