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