1use serde::{Deserialize, Serialize};
31use stakpak_mcp_client::McpClient;
32use stakpak_mcp_proxy::client::{ClientPoolConfig, ServerConfig};
33use stakpak_mcp_proxy::server::start_proxy_server;
34use stakpak_shared::cert_utils::{CertificateChain, MtlsIdentity};
35use std::collections::HashMap;
36use std::path::Path;
37use std::sync::Arc;
38use tokio::io::AsyncBufReadExt;
39use tokio::net::TcpListener;
40use tokio::process::Child;
41use tokio::sync::{broadcast, watch};
42
43const TRUSTED_CLIENT_CA_ENV: &str = "STAKPAK_MCP_CLIENT_CA";
45
46#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(rename_all = "lowercase")]
51pub enum SandboxMode {
52 Ephemeral,
55 #[default]
58 Persistent,
59}
60
61impl std::fmt::Display for SandboxMode {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 match self {
64 SandboxMode::Ephemeral => write!(f, "ephemeral"),
65 SandboxMode::Persistent => write!(f, "persistent"),
66 }
67 }
68}
69
70#[derive(Clone, Debug)]
74pub struct SandboxConfig {
75 pub warden_path: String,
77 pub image: String,
79 pub volumes: Vec<String>,
81 pub mode: SandboxMode,
83}
84
85#[derive(Clone, Debug, Serialize, Deserialize)]
89pub struct SandboxHealth {
90 pub healthy: bool,
92 pub consecutive_ok: u64,
94 pub consecutive_failures: u64,
96 pub last_ok: Option<String>,
98 pub last_error: Option<String>,
100 pub total_respawn_attempts: u64,
102}
103
104impl Default for SandboxHealth {
105 fn default() -> Self {
106 Self {
107 healthy: true,
108 consecutive_ok: 0,
109 consecutive_failures: 0,
110 last_ok: None,
111 last_error: None,
112 total_respawn_attempts: 0,
113 }
114 }
115}
116
117const HEALTH_CHECK_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
119
120const RESPAWN_THRESHOLD: u64 = 3;
122
123const MAX_RESPAWN_ATTEMPTS: u64 = 5;
125
126pub struct PersistentSandbox {
134 inner: Arc<tokio::sync::RwLock<SandboxedMcpServer>>,
135 config: SandboxConfig,
136 health_rx: watch::Receiver<SandboxHealth>,
137 monitor_handle: tokio::task::JoinHandle<()>,
139}
140
141impl PersistentSandbox {
142 pub async fn spawn(config: &SandboxConfig) -> Result<Self, String> {
144 tracing::info!(image = %config.image, "Spawning persistent sandbox container");
145 let inner = SandboxedMcpServer::spawn(config).await?;
146 tracing::info!("Persistent sandbox ready");
147
148 let initial_health = SandboxHealth {
149 healthy: true,
150 consecutive_ok: 1,
151 consecutive_failures: 0,
152 last_ok: Some(chrono::Utc::now().to_rfc3339()),
153 last_error: None,
154 total_respawn_attempts: 0,
155 };
156 let (health_tx, health_rx) = watch::channel(initial_health);
157
158 let inner = Arc::new(tokio::sync::RwLock::new(inner));
159 let monitor_inner = inner.clone();
160 let monitor_config = config.clone();
161
162 let monitor_handle = tokio::spawn(async move {
163 health_monitor_loop(monitor_inner, monitor_config, health_tx).await;
164 });
165
166 Ok(Self {
167 inner,
168 config: config.clone(),
169 health_rx,
170 monitor_handle,
171 })
172 }
173
174 pub async fn client(&self) -> Arc<McpClient> {
176 self.inner.read().await.client.clone()
177 }
178
179 pub async fn tools(&self) -> Vec<stakai::Tool> {
181 self.inner.read().await.tools.clone()
182 }
183
184 pub fn health(&self) -> SandboxHealth {
186 self.health_rx.borrow().clone()
187 }
188
189 pub fn mode(&self) -> &SandboxMode {
191 &self.config.mode
192 }
193
194 pub async fn shutdown(self) {
196 tracing::info!("Shutting down persistent sandbox");
197 self.monitor_handle.abort();
198 if let Ok(inner) = Arc::try_unwrap(self.inner) {
203 let sandbox = inner.into_inner();
204 sandbox.shutdown().await;
205 } else {
206 tracing::warn!(
207 "Other references to persistent sandbox still exist; container will be cleaned up on process exit"
208 );
209 }
210 }
211
212 pub async fn kill(&self) {
218 tracing::warn!(
219 "Killing persistent sandbox container — in-flight sessions using this sandbox will fail"
220 );
221 self.monitor_handle.abort();
222 self.inner.write().await.teardown().await;
223 tracing::info!("Persistent sandbox container killed");
224 }
225}
226
227async fn health_monitor_loop(
230 inner: Arc<tokio::sync::RwLock<SandboxedMcpServer>>,
231 config: SandboxConfig,
232 health_tx: watch::Sender<SandboxHealth>,
233) {
234 let mut health = SandboxHealth::default();
235
236 loop {
237 tokio::time::sleep(HEALTH_CHECK_INTERVAL).await;
238
239 let check_result = {
240 let sandbox = inner.read().await;
241 tokio::time::timeout(
244 std::time::Duration::from_secs(10),
245 stakpak_mcp_client::get_tools(&sandbox.client),
246 )
247 .await
248 };
249
250 match check_result {
251 Ok(Ok(_tools)) => {
252 health.healthy = true;
253 health.consecutive_ok = health.consecutive_ok.saturating_add(1);
254 health.consecutive_failures = 0;
255 health.last_ok = Some(chrono::Utc::now().to_rfc3339());
256 health.last_error = None;
257 tracing::debug!(
258 consecutive_ok = health.consecutive_ok,
259 "Persistent sandbox health check passed"
260 );
261 }
262 Ok(Err(e)) => {
263 let err_msg = format!("MCP error: {e}");
264 health.healthy = false;
265 health.consecutive_ok = 0;
266 health.consecutive_failures = health.consecutive_failures.saturating_add(1);
267 health.last_error = Some(err_msg.clone());
268 tracing::warn!(
269 consecutive_failures = health.consecutive_failures,
270 error = %err_msg,
271 "Persistent sandbox health check failed"
272 );
273 }
274 Err(_timeout) => {
275 health.healthy = false;
276 health.consecutive_ok = 0;
277 health.consecutive_failures = health.consecutive_failures.saturating_add(1);
278 health.last_error = Some("Health check timed out (10s)".to_string());
279 tracing::warn!(
280 consecutive_failures = health.consecutive_failures,
281 "Persistent sandbox health check timed out"
282 );
283 }
284 }
285
286 if health.consecutive_failures >= RESPAWN_THRESHOLD {
288 health.total_respawn_attempts = health.total_respawn_attempts.saturating_add(1);
289
290 if health.total_respawn_attempts > MAX_RESPAWN_ATTEMPTS {
291 tracing::error!(
292 total_attempts = health.total_respawn_attempts,
293 "Persistent sandbox exceeded maximum respawn attempts ({}) — giving up. \
294 The server cannot operate without a healthy sandbox. Shutting down.",
295 MAX_RESPAWN_ATTEMPTS
296 );
297 health.last_error = Some(format!(
298 "Exceeded max respawn attempts ({}); sandbox permanently failed",
299 MAX_RESPAWN_ATTEMPTS
300 ));
301 let _ = health_tx.send(health);
302 return;
306 }
307
308 tracing::error!(
309 failures = health.consecutive_failures,
310 attempt = health.total_respawn_attempts,
311 max_attempts = MAX_RESPAWN_ATTEMPTS,
312 "Persistent sandbox unhealthy — attempting respawn"
313 );
314
315 let mut sandbox = inner.write().await;
317
318 sandbox.teardown().await;
320
321 match SandboxedMcpServer::spawn(&config).await {
322 Ok(new_sandbox) => {
323 *sandbox = new_sandbox;
324 health.healthy = true;
325 health.consecutive_ok = 1;
326 health.consecutive_failures = 0;
327 health.last_ok = Some(chrono::Utc::now().to_rfc3339());
328 health.last_error = None;
329 tracing::info!("Persistent sandbox respawned successfully");
330 }
331 Err(e) => {
332 health.last_error = Some(format!("Respawn failed: {e}"));
333 tracing::error!(error = %e, "Failed to respawn persistent sandbox");
334 }
336 }
337 }
338
339 let _ = health_tx.send(health.clone());
341 }
342}
343
344pub struct SandboxedMcpServer {
348 pub client: Arc<McpClient>,
350 pub tools: Vec<stakai::Tool>,
352 proxy_shutdown_tx: broadcast::Sender<()>,
354 container_process: Child,
356}
357
358impl SandboxedMcpServer {
359 pub async fn spawn(config: &SandboxConfig) -> Result<Self, String> {
369 let client_identity = MtlsIdentity::generate_client()
371 .map_err(|e| format!("Failed to generate client identity: {e}"))?;
372
373 let client_ca_pem = client_identity
374 .ca_cert_pem()
375 .map_err(|e| format!("Failed to get client CA PEM: {e}"))?;
376
377 let container_host_port = find_free_port()
379 .await
380 .map_err(|e| format!("Failed to find free port for sandbox: {e}"))?;
381
382 let mut container_process =
384 spawn_warden_container(config, container_host_port, &client_ca_pem)
385 .await
386 .map_err(|e| format!("Failed to spawn sandbox container: {e}"))?;
387
388 let server_ca_pem = parse_server_ca_from_stdout(&mut container_process).await?;
390 tracing::info!(
391 "Parsed server CA from container stdout ({} bytes)",
392 server_ca_pem.len()
393 );
394
395 let container_client_config = client_identity
397 .create_client_config(&server_ca_pem)
398 .map_err(|e| format!("Failed to create client TLS config: {e}"))?;
399
400 let server_url = format!("https://127.0.0.1:{container_host_port}/mcp");
402 tracing::info!(url = %server_url, "Waiting for sandbox MCP server to be ready");
403 wait_for_server_ready(&server_url, &container_client_config).await?;
404 tracing::info!("Sandbox MCP server is ready");
405
406 let (proxy_shutdown_tx, proxy_shutdown_rx) = broadcast::channel::<()>(1);
408
409 let proxy_binding = find_available_binding("sandbox proxy").await?;
410 let proxy_url = format!("https://{}/mcp", proxy_binding.address);
411
412 let proxy_cert_chain = Arc::new(
413 CertificateChain::generate()
414 .map_err(|e| format!("Failed to generate proxy certificates: {e}"))?,
415 );
416
417 let pool_config = build_sandbox_proxy_config(server_url, Arc::new(container_client_config));
418
419 let proxy_chain_for_server = proxy_cert_chain.clone();
420 let proxy_listener = proxy_binding.listener;
421 tokio::spawn(async move {
422 if let Err(e) = start_proxy_server(
423 pool_config,
424 proxy_listener,
425 proxy_chain_for_server,
426 true, false, Some(proxy_shutdown_rx),
429 )
430 .await
431 {
432 tracing::error!("Sandbox proxy error: {e}");
433 }
434 });
435
436 tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
438
439 let client = connect_to_proxy(&proxy_url, proxy_cert_chain).await?;
441
442 let mcp_tools = stakpak_mcp_client::get_tools(&client)
444 .await
445 .map_err(|e| format!("Failed to get sandbox tools: {e}"))?;
446
447 let tools = mcp_tools
448 .into_iter()
449 .map(|tool| stakai::Tool {
450 tool_type: "function".to_string(),
451 function: stakai::ToolFunction {
452 name: tool.name.as_ref().to_string(),
453 description: tool
454 .description
455 .as_ref()
456 .map(std::string::ToString::to_string)
457 .unwrap_or_default(),
458 parameters: serde_json::Value::Object((*tool.input_schema).clone()),
459 },
460 provider_options: None,
461 })
462 .collect();
463
464 Ok(Self {
465 client,
466 tools,
467 proxy_shutdown_tx,
468 container_process,
469 })
470 }
471
472 pub async fn shutdown(mut self) {
474 self.teardown().await;
475 }
476
477 pub async fn teardown(&mut self) {
483 let _ = self.proxy_shutdown_tx.send(());
484
485 #[cfg(unix)]
488 if let Some(pid) = self.container_process.id() {
489 let _ = tokio::process::Command::new("kill")
490 .args(["-INT", &pid.to_string()])
491 .output()
492 .await;
493 }
494
495 match tokio::time::timeout(
497 std::time::Duration::from_secs(10),
498 self.container_process.wait(),
499 )
500 .await
501 {
502 Ok(Ok(status)) => {
503 tracing::debug!(exit_status = ?status, "Warden process exited gracefully");
504 }
505 _ => {
506 tracing::warn!("Warden process did not exit in 10s — force killing");
507 let _ = self.container_process.kill().await;
508 let _ = self.container_process.wait().await;
509 }
510 }
511 }
512}
513
514async fn spawn_warden_container(
515 config: &SandboxConfig,
516 host_port: u16,
517 client_ca_pem: &str,
518) -> Result<Child, String> {
519 use stakpak_shared::container::{expand_volume_path, is_named_volume};
520
521 let mut cmd = tokio::process::Command::new(&config.warden_path);
522 cmd.arg("wrap");
523 cmd.arg(&config.image);
524
525 for vol in &config.volumes {
527 let expanded = expand_volume_path(vol);
528 let host_path = expanded.split(':').next().unwrap_or(&expanded);
529 if is_named_volume(host_path) || Path::new(host_path).exists() {
533 cmd.args(["--volume", &expanded]);
534 }
535 }
536
537 cmd.args(["-p", &format!("127.0.0.1:{host_port}:8080")]);
540
541 cmd.args(["--env", "STAKPAK_SKIP_WARDEN=1"]);
543
544 cmd.args(["--env", "STAKPAK_MCP_PORT=8080"]);
547
548 cmd.args(["--env", &format!("{TRUSTED_CLIENT_CA_ENV}={client_ca_pem}")]);
550
551 if let Ok(api_key) = std::env::var("STAKPAK_API_KEY") {
553 cmd.args(["--env", &format!("STAKPAK_API_KEY={api_key}")]);
554 }
555 if let Ok(profile) = std::env::var("STAKPAK_PROFILE") {
556 cmd.args(["--env", &format!("STAKPAK_PROFILE={profile}")]);
557 }
558 if let Ok(endpoint) = std::env::var("STAKPAK_API_ENDPOINT") {
559 cmd.args(["--env", &format!("STAKPAK_API_ENDPOINT={endpoint}")]);
560 }
561
562 cmd.args(["--", "stakpak", "mcp", "start"]);
565
566 cmd.stdout(std::process::Stdio::piped());
567 cmd.stderr(std::process::Stdio::piped());
568 cmd.stdin(std::process::Stdio::null());
569
570 let child = cmd
571 .spawn()
572 .map_err(|e| format!("Failed to spawn warden process: {e}"))?;
573
574 Ok(child)
575}
576
577async fn parse_server_ca_from_stdout(process: &mut Child) -> Result<String, String> {
588 let stdout = process
589 .stdout
590 .take()
591 .ok_or_else(|| "Container stdout not captured".to_string())?;
592
593 let mut reader = tokio::io::BufReader::new(stdout);
594 let mut server_ca_pem = String::new();
595 let mut in_server_ca = false;
596 let mut line = String::new();
597
598 let timeout_duration = tokio::time::Duration::from_secs(60);
599 let deadline = tokio::time::Instant::now() + timeout_duration;
600
601 tracing::debug!("Starting to read container stdout for server CA...");
602
603 loop {
604 line.clear();
605 let bytes_read = tokio::time::timeout_at(deadline, reader.read_line(&mut line))
606 .await
607 .map_err(|_| {
608 "Timed out waiting for container to output server CA certificate".to_string()
609 })?
610 .map_err(|e| format!("Failed to read container stdout: {e}"))?;
611
612 if bytes_read == 0 {
613 tracing::error!("Container stdout EOF before server CA was found");
614 return Err("Container exited before outputting server CA certificate".to_string());
615 }
616
617 let trimmed = line.trim();
618 tracing::debug!(line = %trimmed, bytes = bytes_read, "Read line from container stdout");
619
620 if trimmed == "---BEGIN STAKPAK SERVER CA---" {
621 in_server_ca = true;
622 continue;
623 }
624
625 if trimmed == "---END STAKPAK SERVER CA---" {
626 tracing::debug!("Found end of server CA block");
627 break;
628 }
629
630 if in_server_ca {
631 server_ca_pem.push_str(trimmed);
632 server_ca_pem.push('\n');
633 }
634 }
635
636 let server_ca_pem = server_ca_pem.trim().to_string();
637
638 if server_ca_pem.is_empty() {
639 return Err("Failed to parse server CA certificate from container output".to_string());
640 }
641
642 Ok(server_ca_pem)
643}
644
645async fn wait_for_server_ready(
646 url: &str,
647 client_config: &rustls::ClientConfig,
648) -> Result<(), String> {
649 let http_client = reqwest::Client::builder()
650 .use_preconfigured_tls(client_config.clone())
651 .build()
652 .map_err(|e| format!("Failed to build readiness check client: {e}"))?;
653
654 let mut last_error = String::new();
655 for attempt in 0..30 {
656 tokio::time::sleep(tokio::time::Duration::from_millis(if attempt < 5 {
657 500
658 } else {
659 1000
660 }))
661 .await;
662
663 match http_client.get(url).send().await {
664 Ok(_) => {
665 tracing::info!(attempt, "Sandbox MCP server ready");
666 return Ok(());
667 }
668 Err(e) => {
669 last_error = format!("{e:?}");
670 tracing::debug!(attempt, error = %last_error, "Readiness check failed");
671 }
672 }
673 }
674
675 Err(format!(
676 "Sandbox MCP server failed to become ready after 30 attempts: {last_error}"
677 ))
678}
679
680struct ProxyBinding {
681 address: String,
682 listener: TcpListener,
683}
684
685async fn find_available_binding(purpose: &str) -> Result<ProxyBinding, String> {
686 let listener = TcpListener::bind("127.0.0.1:0")
687 .await
688 .map_err(|e| format!("Failed to bind port for {purpose}: {e}"))?;
689 let addr = listener
690 .local_addr()
691 .map_err(|e| format!("Failed to get address for {purpose}: {e}"))?;
692 Ok(ProxyBinding {
693 address: addr.to_string(),
694 listener,
695 })
696}
697
698async fn find_free_port() -> Result<u16, String> {
702 let listener = TcpListener::bind("127.0.0.1:0")
703 .await
704 .map_err(|e| format!("Failed to bind ephemeral port: {e}"))?;
705 let port = listener
706 .local_addr()
707 .map_err(|e| format!("Failed to get ephemeral port: {e}"))?
708 .port();
709 drop(listener);
711 Ok(port)
712}
713
714fn build_sandbox_proxy_config(
715 sandbox_server_url: String,
716 client_tls_config: Arc<rustls::ClientConfig>,
717) -> ClientPoolConfig {
718 let mut servers: HashMap<String, ServerConfig> = HashMap::new();
719
720 servers.insert(
723 "stakpak".to_string(),
724 ServerConfig::Http {
725 url: sandbox_server_url,
726 headers: None,
727 certificate_chain: Arc::new(None),
728 client_tls_config: Some(client_tls_config),
729 },
730 );
731
732 servers.insert(
734 "paks".to_string(),
735 ServerConfig::Http {
736 url: "https://apiv2.stakpak.dev/v1/paks/mcp".to_string(),
737 headers: None,
738 certificate_chain: Arc::new(None),
739 client_tls_config: None,
740 },
741 );
742
743 ClientPoolConfig::with_servers(servers)
744}
745
746async fn connect_to_proxy(
747 proxy_url: &str,
748 cert_chain: Arc<CertificateChain>,
749) -> Result<Arc<McpClient>, String> {
750 const MAX_RETRIES: u32 = 5;
751 let mut retry_delay = tokio::time::Duration::from_millis(50);
752 let mut last_error = None;
753
754 for attempt in 1..=MAX_RETRIES {
755 match stakpak_mcp_client::connect_https(proxy_url, Some(cert_chain.clone()), None).await {
756 Ok(client) => return Ok(Arc::new(client)),
757 Err(e) => {
758 last_error = Some(e);
759 if attempt < MAX_RETRIES {
760 tokio::time::sleep(retry_delay).await;
761 retry_delay *= 2;
762 }
763 }
764 }
765 }
766
767 Err(format!(
768 "Failed to connect to sandbox proxy after {MAX_RETRIES} retries: {}",
769 last_error.map(|e| e.to_string()).unwrap_or_default()
770 ))
771}
772
773#[cfg(test)]
774mod tests {
775 #[test]
776 fn parse_server_ca_from_structured_output() {
777 let output = "\
778🔐 mTLS enabled - independent identity (sandbox mode)
779---BEGIN STAKPAK SERVER CA---
780-----BEGIN CERTIFICATE-----
781MIIB0zCCAXmgAwIBAgIUFAKE=
782-----END CERTIFICATE-----
783---END STAKPAK SERVER CA---
784MCP server started at https://0.0.0.0:8080/mcp
785";
786
787 let expected_ca = "\
788-----BEGIN CERTIFICATE-----
789MIIB0zCCAXmgAwIBAgIUFAKE=
790-----END CERTIFICATE-----";
791
792 let mut server_ca_pem = String::new();
794 let mut in_server_ca = false;
795
796 for line in output.lines() {
797 let trimmed = line.trim();
798 if trimmed == "---BEGIN STAKPAK SERVER CA---" {
799 in_server_ca = true;
800 continue;
801 }
802 if trimmed == "---END STAKPAK SERVER CA---" {
803 break;
804 }
805 if in_server_ca {
806 server_ca_pem.push_str(trimmed);
807 server_ca_pem.push('\n');
808 }
809 }
810
811 assert_eq!(server_ca_pem.trim(), expected_ca);
812 }
813
814 #[test]
815 fn mtls_identity_cross_trust() {
816 use stakpak_shared::cert_utils::MtlsIdentity;
817
818 let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
820
821 let client_identity = MtlsIdentity::generate_client().expect("generate client identity");
823 let server_identity = MtlsIdentity::generate_server().expect("generate server identity");
824
825 let client_ca_pem = client_identity.ca_cert_pem().expect("client CA PEM");
826 let server_ca_pem = server_identity.ca_cert_pem().expect("server CA PEM");
827
828 let _server_config = server_identity
830 .create_server_config(&client_ca_pem)
831 .expect("server config with client CA trust");
832 let _client_config = client_identity
833 .create_client_config(&server_ca_pem)
834 .expect("client config with server CA trust");
835
836 assert!(client_ca_pem.contains("BEGIN CERTIFICATE"));
839 assert!(server_ca_pem.contains("BEGIN CERTIFICATE"));
840 assert!(!client_ca_pem.contains("PRIVATE KEY"));
841 assert!(!server_ca_pem.contains("PRIVATE KEY"));
842 }
843
844 #[test]
847 fn expand_volume_path_leaves_named_volumes_unchanged() {
848 use stakpak_shared::container::expand_volume_path;
849 let named = "stakpak-aqua-cache:/home/agent/.local/share/aquaproj-aqua";
850 assert_eq!(expand_volume_path(named), named);
851 }
852
853 #[test]
856 fn named_volume_is_detected_correctly() {
857 use stakpak_shared::container::is_named_volume;
858 let cases = vec![
859 ("stakpak-aqua-cache", true),
860 ("my-volume", true),
861 ("./relative/path", false),
862 ("/absolute/path", false),
863 ("relative/with/slash", false),
864 (".", false),
865 ];
866 for (host_part, expected) in cases {
867 assert_eq!(
868 is_named_volume(host_part),
869 expected,
870 "host_part={host_part:?} expected named={expected}"
871 );
872 }
873 }
874
875 #[test]
876 fn sandbox_mode_default_is_persistent() {
877 assert_eq!(
878 super::SandboxMode::default(),
879 super::SandboxMode::Persistent
880 );
881 }
882
883 #[test]
884 fn sandbox_mode_serde_roundtrip() {
885 #[derive(serde::Serialize, serde::Deserialize)]
886 struct Wrapper {
887 #[serde(default)]
888 mode: super::SandboxMode,
889 }
890
891 let json = serde_json::json!({"mode": "persistent"});
893 let w: Wrapper = serde_json::from_value(json).expect("deserialize persistent");
894 assert_eq!(w.mode, super::SandboxMode::Persistent);
895
896 let json = serde_json::json!({"mode": "ephemeral"});
898 let w: Wrapper = serde_json::from_value(json).expect("deserialize ephemeral");
899 assert_eq!(w.mode, super::SandboxMode::Ephemeral);
900
901 let json = serde_json::json!({});
903 let w: Wrapper = serde_json::from_value(json).expect("deserialize default");
904 assert_eq!(w.mode, super::SandboxMode::Persistent);
905
906 assert_eq!(super::SandboxMode::Persistent.to_string(), "persistent");
908 assert_eq!(super::SandboxMode::Ephemeral.to_string(), "ephemeral");
909 }
910
911 #[test]
912 fn sandbox_health_default_is_healthy() {
913 let h = super::SandboxHealth::default();
914 assert!(h.healthy);
915 assert_eq!(h.consecutive_ok, 0);
916 assert_eq!(h.consecutive_failures, 0);
917 assert!(h.last_ok.is_none());
918 assert!(h.last_error.is_none());
919 assert_eq!(h.total_respawn_attempts, 0);
920 }
921}