1use 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
17pub const SESSION_SANDBOX_CAPABILITY_ID: &str = "session_sandbox";
19pub const SESSION_SANDBOX_SECRET_NAME: &str = "session_sandbox";
21pub const DEFAULT_SESSION_SANDBOX_IDLE_TIMEOUT_SECS: u64 = 180;
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26pub struct SessionSandboxConfig {
27 pub provider: String,
29 #[serde(default = "default_true")]
31 pub auto_start: bool,
32 #[serde(default = "default_idle_timeout")]
34 pub idle_pause_after_seconds: u64,
35 #[serde(default = "default_provider_config")]
37 pub provider_config: Value,
38 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
57pub struct SessionSandboxInitConfig {
58 #[serde(default)]
59 pub commands: Vec<String>,
60}
61
62#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
64#[serde(rename_all = "snake_case")]
65pub enum SessionSandboxStatus {
66 Running,
67 Paused,
68 Lost,
70}
71
72#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
74pub struct SessionSandboxInstance {
75 pub external_id: String,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub display_name: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub workspace_path: Option<String>,
83 #[serde(default)]
85 pub provider_state: Value,
86 #[serde(default)]
88 pub metadata: Value,
89}
90
91#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142pub struct SessionSandboxWriteFileResponse {
143 pub path: String,
144 pub bytes_written: usize,
145}
146
147#[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#[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 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
237pub struct SessionSandboxProviderPlugin {
239 pub factory: fn() -> Box<dyn SessionSandboxProvider>,
240}
241
242inventory::collect!(SessionSandboxProviderPlugin);
243
244pub 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
254pub 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
278pub 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
301pub 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
323pub 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
339pub 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
400pub 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
433pub 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
461pub 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
517pub 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
536pub 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 "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}