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_concurrency_class("session_sandbox")
506        .with_open_world(true)
507        .with_requires_secrets(true)
508        .with_long_running(true)
509}
510
511fn default_true() -> bool {
512    true
513}
514
515fn default_idle_timeout() -> u64 {
516    DEFAULT_SESSION_SANDBOX_IDLE_TIMEOUT_SECS
517}
518
519fn default_provider_config() -> Value {
520    json!({})
521}
522
523fn default_output_mode() -> String {
524    // EVE-489: persistence-first default for exec-style sandbox tools.
525    "auto".to_string()
526}
527
528fn now_rfc3339() -> String {
529    Utc::now().to_rfc3339()
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use crate::traits::{SecretInfo, SessionStorageStore};
536    use async_trait::async_trait;
537    use chrono::Utc;
538    use std::collections::HashMap;
539    use std::sync::{Arc, LazyLock, Mutex};
540
541    #[derive(Clone, Default)]
542    struct MemorySecrets {
543        secrets: Arc<Mutex<HashMap<String, String>>>,
544    }
545
546    #[async_trait]
547    impl SessionStorageStore for MemorySecrets {
548        async fn set_value(
549            &self,
550            _session_id: crate::SessionId,
551            _key: &str,
552            _value: &str,
553        ) -> crate::Result<()> {
554            unreachable!()
555        }
556
557        async fn get_value(
558            &self,
559            _session_id: crate::SessionId,
560            _key: &str,
561        ) -> crate::Result<Option<String>> {
562            unreachable!()
563        }
564
565        async fn delete_value(
566            &self,
567            _session_id: crate::SessionId,
568            _key: &str,
569        ) -> crate::Result<bool> {
570            unreachable!()
571        }
572
573        async fn list_keys(
574            &self,
575            _session_id: crate::SessionId,
576        ) -> crate::Result<Vec<crate::KeyInfo>> {
577            unreachable!()
578        }
579
580        async fn set_secret(
581            &self,
582            _session_id: crate::SessionId,
583            name: &str,
584            value: &str,
585        ) -> crate::Result<()> {
586            self.secrets
587                .lock()
588                .unwrap()
589                .insert(name.to_string(), value.to_string());
590            Ok(())
591        }
592
593        async fn get_secret(
594            &self,
595            _session_id: crate::SessionId,
596            name: &str,
597        ) -> crate::Result<Option<String>> {
598            Ok(self.secrets.lock().unwrap().get(name).cloned())
599        }
600
601        async fn delete_secret(
602            &self,
603            _session_id: crate::SessionId,
604            name: &str,
605        ) -> crate::Result<bool> {
606            Ok(self.secrets.lock().unwrap().remove(name).is_some())
607        }
608
609        async fn list_secrets(
610            &self,
611            _session_id: crate::SessionId,
612        ) -> crate::Result<Vec<SecretInfo>> {
613            Ok(self
614                .secrets
615                .lock()
616                .unwrap()
617                .keys()
618                .map(|name| SecretInfo {
619                    name: name.clone(),
620                    created_at: Utc::now(),
621                    updated_at: Utc::now(),
622                })
623                .collect())
624        }
625    }
626
627    #[test]
628    fn extracts_valid_session_sandbox_config() {
629        let config =
630            session_sandbox_config_from_capabilities(&[AgentCapabilityConfig::with_config(
631                SESSION_SANDBOX_CAPABILITY_ID,
632                serde_json::json!({
633                    "provider": "daytona",
634                    "auto_start": false,
635                    "idle_pause_after_seconds": 90,
636                    "init": { "commands": ["echo ready"] }
637                }),
638            )])
639            .unwrap()
640            .unwrap();
641
642        assert_eq!(config.provider, "daytona");
643        assert!(!config.auto_start);
644        assert_eq!(config.idle_pause_after_seconds, 90);
645        assert_eq!(config.provider_config, json!({}));
646        assert_eq!(config.init.commands, vec!["echo ready"]);
647    }
648
649    #[test]
650    fn rejects_missing_provider() {
651        let err = session_sandbox_config_from_capabilities(&[AgentCapabilityConfig::with_config(
652            SESSION_SANDBOX_CAPABILITY_ID,
653            serde_json::json!({ "auto_start": true }),
654        )])
655        .unwrap_err();
656
657        assert!(err.contains("provider"));
658    }
659
660    #[tokio::test]
661    async fn session_sandbox_state_round_trip() {
662        let storage = Arc::new(MemorySecrets::default());
663        let context = ToolContext::with_storage_store(crate::SessionId::new(), storage);
664
665        let state = SessionSandboxState {
666            provider: "daytona".to_string(),
667            status: SessionSandboxStatus::Running,
668            instance: SessionSandboxInstance {
669                external_id: "sb_test".to_string(),
670                display_name: Some("Sandbox".to_string()),
671                workspace_path: Some("/home/daytona".to_string()),
672                provider_state: serde_json::json!({"sandbox_id":"sb_test"}),
673                metadata: serde_json::json!({"state":"started"}),
674            },
675            init_completed_at: Some(now_rfc3339()),
676            last_init_error: None,
677            created_at: now_rfc3339(),
678            updated_at: now_rfc3339(),
679        };
680
681        save_session_sandbox_state(&context, &state).await.unwrap();
682        let loaded = load_session_sandbox_state(&context).await.unwrap().unwrap();
683        assert_eq!(loaded.provider, "daytona");
684        assert_eq!(loaded.instance.external_id, "sb_test");
685        assert_eq!(loaded.status, SessionSandboxStatus::Running);
686    }
687
688    #[derive(Clone)]
689    struct TestProviderSandboxState {
690        remote_status: SessionSandboxStatus,
691        resume_calls: usize,
692        exec_commands: Vec<String>,
693    }
694
695    #[derive(Default)]
696    struct TestProviderState {
697        sandboxes: HashMap<String, TestProviderSandboxState>,
698    }
699
700    static TEST_PROVIDER_STATE: LazyLock<Mutex<TestProviderState>> =
701        LazyLock::new(|| Mutex::new(TestProviderState::default()));
702
703    fn sandbox_state_mut<'a>(
704        state: &'a mut TestProviderState,
705        external_id: &str,
706    ) -> &'a mut TestProviderSandboxState {
707        state
708            .sandboxes
709            .entry(external_id.to_string())
710            .or_insert_with(|| TestProviderSandboxState {
711                remote_status: SessionSandboxStatus::Running,
712                resume_calls: 0,
713                exec_commands: Vec::new(),
714            })
715    }
716
717    fn test_provider_state(external_id: &str) -> TestProviderSandboxState {
718        TEST_PROVIDER_STATE
719            .lock()
720            .unwrap_or_else(|poisoned| poisoned.into_inner())
721            .sandboxes
722            .get(external_id)
723            .cloned()
724            .unwrap_or(TestProviderSandboxState {
725                remote_status: SessionSandboxStatus::Running,
726                resume_calls: 0,
727                exec_commands: Vec::new(),
728            })
729    }
730
731    fn reset_test_provider_state(external_id: &str, remote_status: SessionSandboxStatus) {
732        let mut state = TEST_PROVIDER_STATE
733            .lock()
734            .unwrap_or_else(|poisoned| poisoned.into_inner());
735        state.sandboxes.insert(
736            external_id.to_string(),
737            TestProviderSandboxState {
738                remote_status,
739                resume_calls: 0,
740                exec_commands: Vec::new(),
741            },
742        );
743    }
744
745    struct CoreTestSessionSandboxProvider;
746
747    inventory::submit! {
748        SessionSandboxProviderPlugin {
749            factory: || Box::new(CoreTestSessionSandboxProvider),
750        }
751    }
752
753    #[async_trait]
754    impl SessionSandboxProvider for CoreTestSessionSandboxProvider {
755        fn id(&self) -> &str {
756            "core-test-session-sandbox"
757        }
758
759        async fn create(
760            &self,
761            _context: &ToolContext,
762            _config: &SessionSandboxConfig,
763        ) -> Result<SessionSandboxInstance, ToolExecutionResult> {
764            let instance = test_instance("sb_created");
765            let mut state = TEST_PROVIDER_STATE
766                .lock()
767                .unwrap_or_else(|poisoned| poisoned.into_inner());
768            sandbox_state_mut(&mut state, &instance.external_id).remote_status =
769                SessionSandboxStatus::Running;
770            Ok(instance)
771        }
772
773        async fn resume(
774            &self,
775            _context: &ToolContext,
776            _config: &SessionSandboxConfig,
777            instance: &SessionSandboxInstance,
778        ) -> Result<SessionSandboxInstance, ToolExecutionResult> {
779            let mut state = TEST_PROVIDER_STATE
780                .lock()
781                .unwrap_or_else(|poisoned| poisoned.into_inner());
782            let sandbox_state = sandbox_state_mut(&mut state, &instance.external_id);
783            sandbox_state.resume_calls += 1;
784            sandbox_state.remote_status = SessionSandboxStatus::Running;
785
786            let mut resumed = instance.clone();
787            resumed.metadata = json!({ "resumed": true });
788            Ok(resumed)
789        }
790
791        async fn pause(
792            &self,
793            _context: &ToolContext,
794            _config: &SessionSandboxConfig,
795            instance: &SessionSandboxInstance,
796        ) -> Result<SessionSandboxInstance, ToolExecutionResult> {
797            Ok(instance.clone())
798        }
799
800        async fn delete(
801            &self,
802            _context: &ToolContext,
803            _config: &SessionSandboxConfig,
804            _instance: &SessionSandboxInstance,
805        ) -> Result<(), ToolExecutionResult> {
806            Ok(())
807        }
808
809        async fn exec(
810            &self,
811            _context: &ToolContext,
812            _config: &SessionSandboxConfig,
813            _instance: &SessionSandboxInstance,
814            request: &SessionSandboxExecRequest,
815        ) -> Result<SessionSandboxExecResponse, ToolExecutionResult> {
816            let mut state = TEST_PROVIDER_STATE
817                .lock()
818                .unwrap_or_else(|poisoned| poisoned.into_inner());
819            sandbox_state_mut(&mut state, &_instance.external_id)
820                .exec_commands
821                .push(request.command.clone());
822
823            Ok(SessionSandboxExecResponse {
824                exit_code: 0,
825                stdout: "ok".to_string(),
826                stderr: String::new(),
827                success: true,
828                truncated: false,
829                total_lines: 1,
830                raw_output: Some("ok".to_string()),
831                hint: None,
832            })
833        }
834
835        async fn read_file(
836            &self,
837            _context: &ToolContext,
838            _config: &SessionSandboxConfig,
839            _instance: &SessionSandboxInstance,
840            path: &str,
841        ) -> Result<SessionSandboxReadFileResponse, ToolExecutionResult> {
842            Ok(SessionSandboxReadFileResponse {
843                path: path.to_string(),
844                content: "data".to_string(),
845                encoding: "text".to_string(),
846            })
847        }
848
849        async fn write_file(
850            &self,
851            _context: &ToolContext,
852            _config: &SessionSandboxConfig,
853            _instance: &SessionSandboxInstance,
854            path: &str,
855            content: &str,
856        ) -> Result<SessionSandboxWriteFileResponse, ToolExecutionResult> {
857            Ok(SessionSandboxWriteFileResponse {
858                path: path.to_string(),
859                bytes_written: content.len(),
860            })
861        }
862
863        async fn status(
864            &self,
865            _context: &ToolContext,
866            _config: &SessionSandboxConfig,
867            state: &SessionSandboxState,
868        ) -> Result<SessionSandboxStatusResponse, ToolExecutionResult> {
869            let provider_state = test_provider_state(&state.instance.external_id);
870
871            Ok(SessionSandboxStatusResponse {
872                provider: state.provider.clone(),
873                session_status: provider_state.remote_status,
874                external_id: state.instance.external_id.clone(),
875                display_name: state.instance.display_name.clone(),
876                workspace_path: state.instance.workspace_path.clone(),
877                metadata: json!({ "remote_status": provider_state.remote_status }),
878            })
879        }
880    }
881
882    fn test_instance(external_id: &str) -> SessionSandboxInstance {
883        SessionSandboxInstance {
884            external_id: external_id.to_string(),
885            display_name: Some("Core Test Sandbox".to_string()),
886            workspace_path: Some("/workspace".to_string()),
887            provider_state: json!({}),
888            metadata: json!({}),
889        }
890    }
891
892    fn test_config_with_init(commands: Vec<&str>) -> SessionSandboxConfig {
893        SessionSandboxConfig {
894            provider: "core-test-session-sandbox".to_string(),
895            auto_start: true,
896            idle_pause_after_seconds: 180,
897            provider_config: json!({}),
898            init: SessionSandboxInitConfig {
899                commands: commands.into_iter().map(ToString::to_string).collect(),
900            },
901        }
902    }
903
904    #[tokio::test]
905    async fn ensure_running_resumes_when_remote_status_drifted_to_paused() {
906        let external_id = "sb_drifted";
907        reset_test_provider_state(external_id, SessionSandboxStatus::Paused);
908
909        let storage = Arc::new(MemorySecrets::default());
910        let context = ToolContext::with_storage_store(crate::SessionId::new(), storage);
911        let state = SessionSandboxState {
912            provider: "core-test-session-sandbox".to_string(),
913            status: SessionSandboxStatus::Running,
914            instance: test_instance(external_id),
915            init_completed_at: Some(now_rfc3339()),
916            last_init_error: None,
917            created_at: now_rfc3339(),
918            updated_at: now_rfc3339(),
919        };
920        save_session_sandbox_state(&context, &state).await.unwrap();
921
922        let resolved = ensure_session_sandbox_running(&context, &test_config_with_init(vec![]))
923            .await
924            .unwrap();
925
926        let provider_state = test_provider_state(external_id);
927        assert_eq!(provider_state.resume_calls, 1);
928        assert_eq!(resolved.status, SessionSandboxStatus::Running);
929        assert_eq!(resolved.instance.metadata, json!({ "resumed": true }));
930    }
931
932    #[tokio::test]
933    async fn ensure_running_retries_init_when_state_is_running_but_init_unfinished() {
934        let external_id = "sb_init_retry";
935        reset_test_provider_state(external_id, SessionSandboxStatus::Running);
936
937        let storage = Arc::new(MemorySecrets::default());
938        let context = ToolContext::with_storage_store(crate::SessionId::new(), storage);
939        let state = SessionSandboxState {
940            provider: "core-test-session-sandbox".to_string(),
941            status: SessionSandboxStatus::Running,
942            instance: test_instance(external_id),
943            init_completed_at: None,
944            last_init_error: Some("previous failure".to_string()),
945            created_at: now_rfc3339(),
946            updated_at: now_rfc3339(),
947        };
948        save_session_sandbox_state(&context, &state).await.unwrap();
949
950        let resolved =
951            ensure_session_sandbox_running(&context, &test_config_with_init(vec!["echo ready"]))
952                .await
953                .unwrap();
954
955        let provider_state = test_provider_state(external_id);
956        assert_eq!(provider_state.exec_commands, vec!["echo ready"]);
957        assert!(resolved.init_completed_at.is_some());
958        assert_eq!(resolved.last_init_error, None);
959    }
960}