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