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}
69
70#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
72pub struct SessionSandboxInstance {
73 pub external_id: String,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub display_name: Option<String>,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub workspace_path: Option<String>,
81 #[serde(default)]
83 pub provider_state: Value,
84 #[serde(default)]
86 pub metadata: Value,
87}
88
89#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
140pub struct SessionSandboxWriteFileResponse {
141 pub path: String,
142 pub bytes_written: usize,
143}
144
145#[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#[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
224pub struct SessionSandboxProviderPlugin {
226 pub factory: fn() -> Box<dyn SessionSandboxProvider>,
227}
228
229inventory::collect!(SessionSandboxProviderPlugin);
230
231pub 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
241pub 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
265pub 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
288pub 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
310pub 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
326pub 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
387pub 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
420pub 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
448pub 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
502pub 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 "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}