Skip to main content

everruns_core/
session_sandbox.rs

1//! Session-owned managed sandbox abstractions.
2//!
3//! `session_sandbox` is the provider-neutral contract for "one managed sandbox
4//! per session". The capability in `capabilities/session_sandbox.rs` exposes
5//! generic `sandbox_*` tools, while integration crates register concrete
6//! providers (Daytona first) through the inventory plugin system below.
7
8use chrono::Utc;
9use serde::{Deserialize, Serialize};
10use serde_json::{Value, json};
11
12use crate::capability_types::AgentCapabilityConfig;
13use crate::tool_types::ToolHints;
14use crate::tools::ToolExecutionResult;
15use crate::traits::ToolContext;
16
17/// Capability id for the managed session sandbox capability.
18pub const SESSION_SANDBOX_CAPABILITY_ID: &str = "session_sandbox";
19/// Secret name used to persist the managed sandbox record for a session.
20pub const SESSION_SANDBOX_SECRET_NAME: &str = "session_sandbox";
21/// Default idle timeout for auto-pausing the managed sandbox.
22pub const DEFAULT_SESSION_SANDBOX_IDLE_TIMEOUT_SECS: u64 = 180;
23
24/// Session sandbox configuration.
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26pub struct SessionSandboxConfig {
27    /// Concrete provider id (e.g. `daytona`).
28    pub provider: String,
29    /// Start the sandbox proactively when the session is created.
30    #[serde(default = "default_true")]
31    pub auto_start: bool,
32    /// Pause the sandbox after this much session inactivity.
33    #[serde(default = "default_idle_timeout")]
34    pub idle_pause_after_seconds: u64,
35    /// Provider-specific extra configuration.
36    #[serde(default = "default_provider_config")]
37    pub provider_config: Value,
38    /// Optional one-time initialization commands executed after create.
39    #[serde(default)]
40    pub init: SessionSandboxInitConfig,
41}
42
43impl Default for SessionSandboxConfig {
44    fn default() -> Self {
45        Self {
46            provider: String::new(),
47            auto_start: true,
48            idle_pause_after_seconds: DEFAULT_SESSION_SANDBOX_IDLE_TIMEOUT_SECS,
49            provider_config: default_provider_config(),
50            init: SessionSandboxInitConfig::default(),
51        }
52    }
53}
54
55/// One-time sandbox initialization.
56#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
57pub struct SessionSandboxInitConfig {
58    #[serde(default)]
59    pub commands: Vec<String>,
60}
61
62/// Runtime lifecycle status of the managed sandbox.
63#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
64#[serde(rename_all = "snake_case")]
65pub enum SessionSandboxStatus {
66    Running,
67    Paused,
68}
69
70/// Provider-owned sandbox instance record persisted in session secrets.
71#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
72pub struct SessionSandboxInstance {
73    /// Provider-specific stable identifier (e.g. Daytona sandbox id).
74    pub external_id: String,
75    /// Optional human-readable name.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub display_name: Option<String>,
78    /// Optional default workspace path inside the sandbox.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub workspace_path: Option<String>,
81    /// Provider-specific non-secret payload needed for resume/ops.
82    #[serde(default)]
83    pub provider_state: Value,
84    /// Provider-specific non-secret metadata for UI/debugging.
85    #[serde(default)]
86    pub metadata: Value,
87}
88
89/// Persisted managed sandbox state.
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
91pub struct SessionSandboxState {
92    pub provider: String,
93    pub status: SessionSandboxStatus,
94    pub instance: SessionSandboxInstance,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub init_completed_at: Option<String>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub last_init_error: Option<String>,
99    pub created_at: String,
100    pub updated_at: String,
101}
102
103/// Provider-neutral exec request.
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105pub struct SessionSandboxExecRequest {
106    pub command: String,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub cwd: Option<String>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub timeout_ms: Option<u64>,
111    #[serde(default = "default_output_mode")]
112    pub output_mode: String,
113}
114
115/// Provider-neutral exec result.
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
117pub struct SessionSandboxExecResponse {
118    pub exit_code: i32,
119    pub stdout: String,
120    pub stderr: String,
121    pub success: bool,
122    pub truncated: bool,
123    pub total_lines: usize,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub raw_output: Option<String>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub hint: Option<String>,
128}
129
130/// Provider-neutral file read result.
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
132pub struct SessionSandboxReadFileResponse {
133    pub path: String,
134    pub content: String,
135    pub encoding: String,
136}
137
138/// Provider-neutral file write result.
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
140pub struct SessionSandboxWriteFileResponse {
141    pub path: String,
142    pub bytes_written: usize,
143}
144
145/// Provider-neutral status view.
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
147pub struct SessionSandboxStatusResponse {
148    pub provider: String,
149    pub session_status: SessionSandboxStatus,
150    pub external_id: String,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub display_name: Option<String>,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub workspace_path: Option<String>,
155    #[serde(default)]
156    pub metadata: Value,
157}
158
159/// Provider trait implemented by integration crates.
160#[async_trait::async_trait]
161pub trait SessionSandboxProvider: Send + Sync {
162    fn id(&self) -> &str;
163
164    async fn create(
165        &self,
166        context: &ToolContext,
167        config: &SessionSandboxConfig,
168    ) -> Result<SessionSandboxInstance, ToolExecutionResult>;
169
170    async fn resume(
171        &self,
172        context: &ToolContext,
173        config: &SessionSandboxConfig,
174        instance: &SessionSandboxInstance,
175    ) -> Result<SessionSandboxInstance, ToolExecutionResult>;
176
177    async fn pause(
178        &self,
179        context: &ToolContext,
180        config: &SessionSandboxConfig,
181        instance: &SessionSandboxInstance,
182    ) -> Result<SessionSandboxInstance, ToolExecutionResult>;
183
184    async fn delete(
185        &self,
186        context: &ToolContext,
187        config: &SessionSandboxConfig,
188        instance: &SessionSandboxInstance,
189    ) -> Result<(), ToolExecutionResult>;
190
191    async fn exec(
192        &self,
193        context: &ToolContext,
194        config: &SessionSandboxConfig,
195        instance: &SessionSandboxInstance,
196        request: &SessionSandboxExecRequest,
197    ) -> Result<SessionSandboxExecResponse, ToolExecutionResult>;
198
199    async fn read_file(
200        &self,
201        context: &ToolContext,
202        config: &SessionSandboxConfig,
203        instance: &SessionSandboxInstance,
204        path: &str,
205    ) -> Result<SessionSandboxReadFileResponse, ToolExecutionResult>;
206
207    async fn write_file(
208        &self,
209        context: &ToolContext,
210        config: &SessionSandboxConfig,
211        instance: &SessionSandboxInstance,
212        path: &str,
213        content: &str,
214    ) -> Result<SessionSandboxWriteFileResponse, ToolExecutionResult>;
215
216    async fn status(
217        &self,
218        context: &ToolContext,
219        config: &SessionSandboxConfig,
220        state: &SessionSandboxState,
221    ) -> Result<SessionSandboxStatusResponse, ToolExecutionResult>;
222}
223
224/// Inventory registration point for concrete session sandbox providers.
225pub struct SessionSandboxProviderPlugin {
226    pub factory: fn() -> Box<dyn SessionSandboxProvider>,
227}
228
229inventory::collect!(SessionSandboxProviderPlugin);
230
231/// Look up a registered provider by id.
232pub fn create_session_sandbox_provider(
233    provider_id: &str,
234) -> Option<Box<dyn SessionSandboxProvider>> {
235    inventory::iter::<SessionSandboxProviderPlugin>
236        .into_iter()
237        .map(|plugin| (plugin.factory)())
238        .find(|provider| provider.id() == provider_id)
239}
240
241/// Extract the effective session sandbox config from a capability list.
242pub fn session_sandbox_config_from_capabilities(
243    capability_configs: &[AgentCapabilityConfig],
244) -> Result<Option<SessionSandboxConfig>, String> {
245    let Some(capability) = capability_configs
246        .iter()
247        .find(|cap| cap.capability_id() == SESSION_SANDBOX_CAPABILITY_ID)
248    else {
249        return Ok(None);
250    };
251
252    let config: SessionSandboxConfig = serde_json::from_value(capability.config.clone())
253        .map_err(|e| format!("Invalid session_sandbox config: {e}"))?;
254
255    if config.provider.trim().is_empty() {
256        return Err("session_sandbox config requires non-empty 'provider'".to_string());
257    }
258    if config.idle_pause_after_seconds == 0 {
259        return Err("session_sandbox config requires idle_pause_after_seconds >= 1".to_string());
260    }
261
262    Ok(Some(config))
263}
264
265/// Load the managed sandbox state from session secret storage.
266pub async fn load_session_sandbox_state(
267    context: &ToolContext,
268) -> Result<Option<SessionSandboxState>, ToolExecutionResult> {
269    let storage = context
270        .storage_store
271        .as_ref()
272        .ok_or_else(|| ToolExecutionResult::tool_error("Storage not available in this context"))?;
273
274    let Some(raw) = storage
275        .get_secret(context.session_id, SESSION_SANDBOX_SECRET_NAME)
276        .await
277        .map_err(ToolExecutionResult::internal_error)?
278    else {
279        return Ok(None);
280    };
281
282    let state: SessionSandboxState = serde_json::from_str(&raw).map_err(|e| {
283        ToolExecutionResult::internal_error_msg(format!("Corrupt session sandbox state: {e}"))
284    })?;
285    Ok(Some(state))
286}
287
288/// Persist the managed sandbox state into session secret storage.
289pub async fn save_session_sandbox_state(
290    context: &ToolContext,
291    state: &SessionSandboxState,
292) -> Result<(), ToolExecutionResult> {
293    let storage = context
294        .storage_store
295        .as_ref()
296        .ok_or_else(|| ToolExecutionResult::tool_error("Storage not available in this context"))?;
297
298    let raw = serde_json::to_string(state).map_err(|e| {
299        ToolExecutionResult::internal_error_msg(format!(
300            "Failed to encode session sandbox state: {e}"
301        ))
302    })?;
303
304    storage
305        .set_secret(context.session_id, SESSION_SANDBOX_SECRET_NAME, &raw)
306        .await
307        .map_err(ToolExecutionResult::internal_error)
308}
309
310/// Delete the managed sandbox state from session secret storage.
311pub async fn delete_session_sandbox_state(
312    context: &ToolContext,
313) -> Result<(), ToolExecutionResult> {
314    let storage = context
315        .storage_store
316        .as_ref()
317        .ok_or_else(|| ToolExecutionResult::tool_error("Storage not available in this context"))?;
318
319    storage
320        .delete_secret(context.session_id, SESSION_SANDBOX_SECRET_NAME)
321        .await
322        .map_err(ToolExecutionResult::internal_error)?;
323    Ok(())
324}
325
326/// Start the managed sandbox when absent and resume it when paused.
327pub async fn ensure_session_sandbox_running(
328    context: &ToolContext,
329    config: &SessionSandboxConfig,
330) -> Result<SessionSandboxState, ToolExecutionResult> {
331    let Some(provider) = create_session_sandbox_provider(&config.provider) else {
332        return Err(ToolExecutionResult::tool_error(format!(
333            "Session sandbox provider '{}' is not registered",
334            config.provider
335        )));
336    };
337
338    match load_session_sandbox_state(context).await? {
339        Some(existing) => {
340            if existing.provider != config.provider {
341                return Err(ToolExecutionResult::tool_error(format!(
342                    "Session sandbox provider mismatch: state has '{}', config requests '{}'",
343                    existing.provider, config.provider
344                )));
345            }
346
347            let mut state = existing;
348            let needs_resume = match state.status {
349                SessionSandboxStatus::Paused => true,
350                SessionSandboxStatus::Running => {
351                    let status = provider.status(context, config, &state).await?;
352                    status.session_status != SessionSandboxStatus::Running
353                }
354            };
355
356            if needs_resume {
357                state.instance = provider.resume(context, config, &state.instance).await?;
358                state.status = SessionSandboxStatus::Running;
359                state.last_init_error = None;
360                state.updated_at = now_rfc3339();
361                save_session_sandbox_state(context, &state).await?;
362            }
363
364            run_session_sandbox_init_if_needed(context, provider.as_ref(), config, &mut state)
365                .await?;
366            Ok(state)
367        }
368        None => {
369            let instance = provider.create(context, config).await?;
370            let mut state = SessionSandboxState {
371                provider: config.provider.clone(),
372                status: SessionSandboxStatus::Running,
373                instance,
374                init_completed_at: None,
375                last_init_error: None,
376                created_at: now_rfc3339(),
377                updated_at: now_rfc3339(),
378            };
379            save_session_sandbox_state(context, &state).await?;
380            run_session_sandbox_init_if_needed(context, provider.as_ref(), config, &mut state)
381                .await?;
382            Ok(state)
383        }
384    }
385}
386
387/// Pause the managed sandbox if it exists and is running.
388pub async fn pause_session_sandbox(
389    context: &ToolContext,
390    config: &SessionSandboxConfig,
391) -> Result<Option<SessionSandboxState>, ToolExecutionResult> {
392    let Some(mut state) = load_session_sandbox_state(context).await? else {
393        return Ok(None);
394    };
395
396    if state.provider != config.provider {
397        return Err(ToolExecutionResult::tool_error(format!(
398            "Session sandbox provider mismatch: state has '{}', config requests '{}'",
399            state.provider, config.provider
400        )));
401    }
402    if state.status == SessionSandboxStatus::Paused {
403        return Ok(Some(state));
404    }
405
406    let Some(provider) = create_session_sandbox_provider(&config.provider) else {
407        return Err(ToolExecutionResult::tool_error(format!(
408            "Session sandbox provider '{}' is not registered",
409            config.provider
410        )));
411    };
412
413    state.instance = provider.pause(context, config, &state.instance).await?;
414    state.status = SessionSandboxStatus::Paused;
415    state.updated_at = now_rfc3339();
416    save_session_sandbox_state(context, &state).await?;
417    Ok(Some(state))
418}
419
420/// Delete the managed sandbox if it exists.
421pub async fn delete_session_sandbox(
422    context: &ToolContext,
423    config: &SessionSandboxConfig,
424) -> Result<bool, ToolExecutionResult> {
425    let Some(state) = load_session_sandbox_state(context).await? else {
426        return Ok(false);
427    };
428
429    if state.provider != config.provider {
430        return Err(ToolExecutionResult::tool_error(format!(
431            "Session sandbox provider mismatch: state has '{}', config requests '{}'",
432            state.provider, config.provider
433        )));
434    }
435
436    let Some(provider) = create_session_sandbox_provider(&config.provider) else {
437        return Err(ToolExecutionResult::tool_error(format!(
438            "Session sandbox provider '{}' is not registered",
439            config.provider
440        )));
441    };
442
443    provider.delete(context, config, &state.instance).await?;
444    delete_session_sandbox_state(context).await?;
445    Ok(true)
446}
447
448/// Execute one-time initialization commands when configured and not yet completed.
449pub async fn run_session_sandbox_init_if_needed(
450    context: &ToolContext,
451    provider: &dyn SessionSandboxProvider,
452    config: &SessionSandboxConfig,
453    state: &mut SessionSandboxState,
454) -> Result<(), ToolExecutionResult> {
455    if state.init_completed_at.is_some() || config.init.commands.is_empty() {
456        return Ok(());
457    }
458
459    for command in &config.init.commands {
460        let response = provider
461            .exec(
462                context,
463                config,
464                &state.instance,
465                &SessionSandboxExecRequest {
466                    command: command.clone(),
467                    cwd: state.instance.workspace_path.clone(),
468                    timeout_ms: None,
469                    output_mode: "concise".to_string(),
470                },
471            )
472            .await?;
473
474        if response.exit_code != 0 {
475            state.last_init_error = Some(format!(
476                "Init command failed with exit code {}: {}",
477                response.exit_code, command
478            ));
479            state.updated_at = now_rfc3339();
480            save_session_sandbox_state(context, state).await?;
481            return Err(ToolExecutionResult::tool_error(format!(
482                "Session sandbox init failed for command '{}': {}",
483                command,
484                if response.stderr.is_empty() {
485                    response.stdout
486                } else if response.stdout.is_empty() {
487                    response.stderr
488                } else {
489                    format!("{}\n{}", response.stdout, response.stderr)
490                }
491            )));
492        }
493    }
494
495    state.init_completed_at = Some(now_rfc3339());
496    state.last_init_error = None;
497    state.updated_at = now_rfc3339();
498    save_session_sandbox_state(context, state).await?;
499    Ok(())
500}
501
502/// Shared hints for stateful remote sandbox tools.
503pub fn session_sandbox_tool_hints() -> ToolHints {
504    ToolHints::default()
505        .with_open_world(true)
506        .with_requires_secrets(true)
507        .with_long_running(true)
508}
509
510fn default_true() -> bool {
511    true
512}
513
514fn default_idle_timeout() -> u64 {
515    DEFAULT_SESSION_SANDBOX_IDLE_TIMEOUT_SECS
516}
517
518fn default_provider_config() -> Value {
519    json!({})
520}
521
522fn default_output_mode() -> String {
523    // EVE-489: persistence-first default for exec-style sandbox tools.
524    "auto".to_string()
525}
526
527fn now_rfc3339() -> String {
528    Utc::now().to_rfc3339()
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    use crate::traits::{SecretInfo, SessionStorageStore};
535    use async_trait::async_trait;
536    use chrono::Utc;
537    use std::collections::HashMap;
538    use std::sync::{Arc, LazyLock, Mutex};
539
540    #[derive(Clone, Default)]
541    struct MemorySecrets {
542        secrets: Arc<Mutex<HashMap<String, String>>>,
543    }
544
545    #[async_trait]
546    impl SessionStorageStore for MemorySecrets {
547        async fn set_value(
548            &self,
549            _session_id: crate::SessionId,
550            _key: &str,
551            _value: &str,
552        ) -> crate::Result<()> {
553            unreachable!()
554        }
555
556        async fn get_value(
557            &self,
558            _session_id: crate::SessionId,
559            _key: &str,
560        ) -> crate::Result<Option<String>> {
561            unreachable!()
562        }
563
564        async fn delete_value(
565            &self,
566            _session_id: crate::SessionId,
567            _key: &str,
568        ) -> crate::Result<bool> {
569            unreachable!()
570        }
571
572        async fn list_keys(
573            &self,
574            _session_id: crate::SessionId,
575        ) -> crate::Result<Vec<crate::KeyInfo>> {
576            unreachable!()
577        }
578
579        async fn set_secret(
580            &self,
581            _session_id: crate::SessionId,
582            name: &str,
583            value: &str,
584        ) -> crate::Result<()> {
585            self.secrets
586                .lock()
587                .unwrap()
588                .insert(name.to_string(), value.to_string());
589            Ok(())
590        }
591
592        async fn get_secret(
593            &self,
594            _session_id: crate::SessionId,
595            name: &str,
596        ) -> crate::Result<Option<String>> {
597            Ok(self.secrets.lock().unwrap().get(name).cloned())
598        }
599
600        async fn delete_secret(
601            &self,
602            _session_id: crate::SessionId,
603            name: &str,
604        ) -> crate::Result<bool> {
605            Ok(self.secrets.lock().unwrap().remove(name).is_some())
606        }
607
608        async fn list_secrets(
609            &self,
610            _session_id: crate::SessionId,
611        ) -> crate::Result<Vec<SecretInfo>> {
612            Ok(self
613                .secrets
614                .lock()
615                .unwrap()
616                .keys()
617                .map(|name| SecretInfo {
618                    name: name.clone(),
619                    created_at: Utc::now(),
620                    updated_at: Utc::now(),
621                })
622                .collect())
623        }
624    }
625
626    #[test]
627    fn extracts_valid_session_sandbox_config() {
628        let config =
629            session_sandbox_config_from_capabilities(&[AgentCapabilityConfig::with_config(
630                SESSION_SANDBOX_CAPABILITY_ID,
631                serde_json::json!({
632                    "provider": "daytona",
633                    "auto_start": false,
634                    "idle_pause_after_seconds": 90,
635                    "init": { "commands": ["echo ready"] }
636                }),
637            )])
638            .unwrap()
639            .unwrap();
640
641        assert_eq!(config.provider, "daytona");
642        assert!(!config.auto_start);
643        assert_eq!(config.idle_pause_after_seconds, 90);
644        assert_eq!(config.provider_config, json!({}));
645        assert_eq!(config.init.commands, vec!["echo ready"]);
646    }
647
648    #[test]
649    fn rejects_missing_provider() {
650        let err = session_sandbox_config_from_capabilities(&[AgentCapabilityConfig::with_config(
651            SESSION_SANDBOX_CAPABILITY_ID,
652            serde_json::json!({ "auto_start": true }),
653        )])
654        .unwrap_err();
655
656        assert!(err.contains("provider"));
657    }
658
659    #[tokio::test]
660    async fn session_sandbox_state_round_trip() {
661        let storage = Arc::new(MemorySecrets::default());
662        let context = ToolContext::with_storage_store(crate::SessionId::new(), storage);
663
664        let state = SessionSandboxState {
665            provider: "daytona".to_string(),
666            status: SessionSandboxStatus::Running,
667            instance: SessionSandboxInstance {
668                external_id: "sb_test".to_string(),
669                display_name: Some("Sandbox".to_string()),
670                workspace_path: Some("/home/daytona".to_string()),
671                provider_state: serde_json::json!({"sandbox_id":"sb_test"}),
672                metadata: serde_json::json!({"state":"started"}),
673            },
674            init_completed_at: Some(now_rfc3339()),
675            last_init_error: None,
676            created_at: now_rfc3339(),
677            updated_at: now_rfc3339(),
678        };
679
680        save_session_sandbox_state(&context, &state).await.unwrap();
681        let loaded = load_session_sandbox_state(&context).await.unwrap().unwrap();
682        assert_eq!(loaded.provider, "daytona");
683        assert_eq!(loaded.instance.external_id, "sb_test");
684        assert_eq!(loaded.status, SessionSandboxStatus::Running);
685    }
686
687    #[derive(Clone)]
688    struct TestProviderSandboxState {
689        remote_status: SessionSandboxStatus,
690        resume_calls: usize,
691        exec_commands: Vec<String>,
692    }
693
694    #[derive(Default)]
695    struct TestProviderState {
696        sandboxes: HashMap<String, TestProviderSandboxState>,
697    }
698
699    static TEST_PROVIDER_STATE: LazyLock<Mutex<TestProviderState>> =
700        LazyLock::new(|| Mutex::new(TestProviderState::default()));
701
702    fn sandbox_state_mut<'a>(
703        state: &'a mut TestProviderState,
704        external_id: &str,
705    ) -> &'a mut TestProviderSandboxState {
706        state
707            .sandboxes
708            .entry(external_id.to_string())
709            .or_insert_with(|| TestProviderSandboxState {
710                remote_status: SessionSandboxStatus::Running,
711                resume_calls: 0,
712                exec_commands: Vec::new(),
713            })
714    }
715
716    fn test_provider_state(external_id: &str) -> TestProviderSandboxState {
717        TEST_PROVIDER_STATE
718            .lock()
719            .unwrap_or_else(|poisoned| poisoned.into_inner())
720            .sandboxes
721            .get(external_id)
722            .cloned()
723            .unwrap_or(TestProviderSandboxState {
724                remote_status: SessionSandboxStatus::Running,
725                resume_calls: 0,
726                exec_commands: Vec::new(),
727            })
728    }
729
730    fn reset_test_provider_state(external_id: &str, remote_status: SessionSandboxStatus) {
731        let mut state = TEST_PROVIDER_STATE
732            .lock()
733            .unwrap_or_else(|poisoned| poisoned.into_inner());
734        state.sandboxes.insert(
735            external_id.to_string(),
736            TestProviderSandboxState {
737                remote_status,
738                resume_calls: 0,
739                exec_commands: Vec::new(),
740            },
741        );
742    }
743
744    struct CoreTestSessionSandboxProvider;
745
746    inventory::submit! {
747        SessionSandboxProviderPlugin {
748            factory: || Box::new(CoreTestSessionSandboxProvider),
749        }
750    }
751
752    #[async_trait]
753    impl SessionSandboxProvider for CoreTestSessionSandboxProvider {
754        fn id(&self) -> &str {
755            "core-test-session-sandbox"
756        }
757
758        async fn create(
759            &self,
760            _context: &ToolContext,
761            _config: &SessionSandboxConfig,
762        ) -> Result<SessionSandboxInstance, ToolExecutionResult> {
763            let instance = test_instance("sb_created");
764            let mut state = TEST_PROVIDER_STATE
765                .lock()
766                .unwrap_or_else(|poisoned| poisoned.into_inner());
767            sandbox_state_mut(&mut state, &instance.external_id).remote_status =
768                SessionSandboxStatus::Running;
769            Ok(instance)
770        }
771
772        async fn resume(
773            &self,
774            _context: &ToolContext,
775            _config: &SessionSandboxConfig,
776            instance: &SessionSandboxInstance,
777        ) -> Result<SessionSandboxInstance, ToolExecutionResult> {
778            let mut state = TEST_PROVIDER_STATE
779                .lock()
780                .unwrap_or_else(|poisoned| poisoned.into_inner());
781            let sandbox_state = sandbox_state_mut(&mut state, &instance.external_id);
782            sandbox_state.resume_calls += 1;
783            sandbox_state.remote_status = SessionSandboxStatus::Running;
784
785            let mut resumed = instance.clone();
786            resumed.metadata = json!({ "resumed": true });
787            Ok(resumed)
788        }
789
790        async fn pause(
791            &self,
792            _context: &ToolContext,
793            _config: &SessionSandboxConfig,
794            instance: &SessionSandboxInstance,
795        ) -> Result<SessionSandboxInstance, ToolExecutionResult> {
796            Ok(instance.clone())
797        }
798
799        async fn delete(
800            &self,
801            _context: &ToolContext,
802            _config: &SessionSandboxConfig,
803            _instance: &SessionSandboxInstance,
804        ) -> Result<(), ToolExecutionResult> {
805            Ok(())
806        }
807
808        async fn exec(
809            &self,
810            _context: &ToolContext,
811            _config: &SessionSandboxConfig,
812            _instance: &SessionSandboxInstance,
813            request: &SessionSandboxExecRequest,
814        ) -> Result<SessionSandboxExecResponse, ToolExecutionResult> {
815            let mut state = TEST_PROVIDER_STATE
816                .lock()
817                .unwrap_or_else(|poisoned| poisoned.into_inner());
818            sandbox_state_mut(&mut state, &_instance.external_id)
819                .exec_commands
820                .push(request.command.clone());
821
822            Ok(SessionSandboxExecResponse {
823                exit_code: 0,
824                stdout: "ok".to_string(),
825                stderr: String::new(),
826                success: true,
827                truncated: false,
828                total_lines: 1,
829                raw_output: Some("ok".to_string()),
830                hint: None,
831            })
832        }
833
834        async fn read_file(
835            &self,
836            _context: &ToolContext,
837            _config: &SessionSandboxConfig,
838            _instance: &SessionSandboxInstance,
839            path: &str,
840        ) -> Result<SessionSandboxReadFileResponse, ToolExecutionResult> {
841            Ok(SessionSandboxReadFileResponse {
842                path: path.to_string(),
843                content: "data".to_string(),
844                encoding: "text".to_string(),
845            })
846        }
847
848        async fn write_file(
849            &self,
850            _context: &ToolContext,
851            _config: &SessionSandboxConfig,
852            _instance: &SessionSandboxInstance,
853            path: &str,
854            content: &str,
855        ) -> Result<SessionSandboxWriteFileResponse, ToolExecutionResult> {
856            Ok(SessionSandboxWriteFileResponse {
857                path: path.to_string(),
858                bytes_written: content.len(),
859            })
860        }
861
862        async fn status(
863            &self,
864            _context: &ToolContext,
865            _config: &SessionSandboxConfig,
866            state: &SessionSandboxState,
867        ) -> Result<SessionSandboxStatusResponse, ToolExecutionResult> {
868            let provider_state = test_provider_state(&state.instance.external_id);
869
870            Ok(SessionSandboxStatusResponse {
871                provider: state.provider.clone(),
872                session_status: provider_state.remote_status,
873                external_id: state.instance.external_id.clone(),
874                display_name: state.instance.display_name.clone(),
875                workspace_path: state.instance.workspace_path.clone(),
876                metadata: json!({ "remote_status": provider_state.remote_status }),
877            })
878        }
879    }
880
881    fn test_instance(external_id: &str) -> SessionSandboxInstance {
882        SessionSandboxInstance {
883            external_id: external_id.to_string(),
884            display_name: Some("Core Test Sandbox".to_string()),
885            workspace_path: Some("/workspace".to_string()),
886            provider_state: json!({}),
887            metadata: json!({}),
888        }
889    }
890
891    fn test_config_with_init(commands: Vec<&str>) -> SessionSandboxConfig {
892        SessionSandboxConfig {
893            provider: "core-test-session-sandbox".to_string(),
894            auto_start: true,
895            idle_pause_after_seconds: 180,
896            provider_config: json!({}),
897            init: SessionSandboxInitConfig {
898                commands: commands.into_iter().map(ToString::to_string).collect(),
899            },
900        }
901    }
902
903    #[tokio::test]
904    async fn ensure_running_resumes_when_remote_status_drifted_to_paused() {
905        let external_id = "sb_drifted";
906        reset_test_provider_state(external_id, SessionSandboxStatus::Paused);
907
908        let storage = Arc::new(MemorySecrets::default());
909        let context = ToolContext::with_storage_store(crate::SessionId::new(), storage);
910        let state = SessionSandboxState {
911            provider: "core-test-session-sandbox".to_string(),
912            status: SessionSandboxStatus::Running,
913            instance: test_instance(external_id),
914            init_completed_at: Some(now_rfc3339()),
915            last_init_error: None,
916            created_at: now_rfc3339(),
917            updated_at: now_rfc3339(),
918        };
919        save_session_sandbox_state(&context, &state).await.unwrap();
920
921        let resolved = ensure_session_sandbox_running(&context, &test_config_with_init(vec![]))
922            .await
923            .unwrap();
924
925        let provider_state = test_provider_state(external_id);
926        assert_eq!(provider_state.resume_calls, 1);
927        assert_eq!(resolved.status, SessionSandboxStatus::Running);
928        assert_eq!(resolved.instance.metadata, json!({ "resumed": true }));
929    }
930
931    #[tokio::test]
932    async fn ensure_running_retries_init_when_state_is_running_but_init_unfinished() {
933        let external_id = "sb_init_retry";
934        reset_test_provider_state(external_id, SessionSandboxStatus::Running);
935
936        let storage = Arc::new(MemorySecrets::default());
937        let context = ToolContext::with_storage_store(crate::SessionId::new(), storage);
938        let state = SessionSandboxState {
939            provider: "core-test-session-sandbox".to_string(),
940            status: SessionSandboxStatus::Running,
941            instance: test_instance(external_id),
942            init_completed_at: None,
943            last_init_error: Some("previous failure".to_string()),
944            created_at: now_rfc3339(),
945            updated_at: now_rfc3339(),
946        };
947        save_session_sandbox_state(&context, &state).await.unwrap();
948
949        let resolved =
950            ensure_session_sandbox_running(&context, &test_config_with_init(vec!["echo ready"]))
951                .await
952                .unwrap();
953
954        let provider_state = test_provider_state(external_id);
955        assert_eq!(provider_state.exec_commands, vec!["echo ready"]);
956        assert!(resolved.init_completed_at.is_some());
957        assert_eq!(resolved.last_init_error, None);
958    }
959}