Skip to main content

everruns_core/capabilities/
session_sandbox.rs

1//! Session Sandbox capability.
2//!
3//! One managed sandbox per session. The concrete provider is chosen by config
4//! (`provider: "daytona"` initially), while the tool surface stays stable.
5
6use super::{Capability, CapabilityStatus};
7pub use crate::session_sandbox::SESSION_SANDBOX_CAPABILITY_ID;
8use crate::session_sandbox::{
9    SessionSandboxConfig, create_session_sandbox_provider, delete_session_sandbox,
10    ensure_session_sandbox_running, load_session_sandbox_state, pause_session_sandbox,
11    session_sandbox_tool_hints,
12};
13use crate::tool_output_sanitizer::{
14    READ_FILE_DEFAULT_LIMIT, build_text_read_file_result, parse_read_file_window_args,
15};
16use crate::tools::{Tool, ToolExecutionResult};
17use crate::traits::ToolContext;
18use crate::truncation_info::TruncationInfo;
19use async_trait::async_trait;
20use serde_json::{Value, json};
21
22pub struct SessionSandboxCapability;
23
24impl Capability for SessionSandboxCapability {
25    fn id(&self) -> &str {
26        SESSION_SANDBOX_CAPABILITY_ID
27    }
28
29    fn name(&self) -> &str {
30        "Session Sandbox"
31    }
32
33    fn description(&self) -> &str {
34        "One managed sandbox owned by the current session. Supports exec and file operations with provider-managed lifecycle."
35    }
36
37    fn status(&self) -> CapabilityStatus {
38        CapabilityStatus::Available
39    }
40
41    fn icon(&self) -> Option<&str> {
42        Some("terminal")
43    }
44
45    fn category(&self) -> Option<&str> {
46        Some("Execution")
47    }
48
49    fn system_prompt_addition(&self) -> Option<&str> {
50        Some(
51            "This session owns one managed sandbox. Use sandbox tools for commands and sandbox file I/O; inspect lifecycle state before lifecycle-sensitive work and pause/resume/delete only when requested or cleaning up.",
52        )
53    }
54
55    fn tools(&self) -> Vec<Box<dyn Tool>> {
56        self.tools_with_config(&json!({}))
57    }
58
59    fn tools_with_config(&self, config: &Value) -> Vec<Box<dyn Tool>> {
60        vec![
61            Box::new(SandboxExecTool::new(config.clone())),
62            Box::new(SandboxReadFileTool::new(config.clone())),
63            Box::new(SandboxWriteFileTool::new(config.clone())),
64            Box::new(SandboxStatusTool::new(config.clone())),
65            Box::new(SandboxManageTool::new(config.clone())),
66        ]
67    }
68
69    fn dependencies(&self) -> Vec<&'static str> {
70        vec!["session_storage"]
71    }
72
73    fn features(&self) -> Vec<&'static str> {
74        vec!["managed_sandbox"]
75    }
76}
77
78fn parse_config(config: &Value) -> Result<SessionSandboxConfig, ToolExecutionResult> {
79    let config: SessionSandboxConfig = serde_json::from_value(config.clone()).map_err(|e| {
80        ToolExecutionResult::tool_error(format!("Invalid session_sandbox capability config: {e}"))
81    })?;
82
83    if config.provider.trim().is_empty() {
84        return Err(ToolExecutionResult::tool_error(
85            "session_sandbox capability requires a non-empty provider",
86        ));
87    }
88    if config.idle_pause_after_seconds == 0 {
89        return Err(ToolExecutionResult::tool_error(
90            "session_sandbox idle_pause_after_seconds must be >= 1",
91        ));
92    }
93
94    Ok(config)
95}
96
97fn provider_for_config(
98    config: &SessionSandboxConfig,
99) -> Result<Box<dyn crate::SessionSandboxProvider>, ToolExecutionResult> {
100    create_session_sandbox_provider(&config.provider).ok_or_else(|| {
101        ToolExecutionResult::tool_error(format!(
102            "Session sandbox provider '{}' is not registered",
103            config.provider
104        ))
105    })
106}
107
108fn build_sandbox_exec_result(
109    response: crate::SessionSandboxExecResponse,
110    cwd: Option<&str>,
111) -> ToolExecutionResult {
112    let mut result = json!({
113        "stdout": response.stdout,
114        "stderr": response.stderr,
115        "exit_code": response.exit_code,
116        "success": response.success,
117        "truncated": response.truncated,
118        "total_lines": response.total_lines,
119        "hint": response.hint,
120    });
121    if let Some(cwd) = cwd {
122        result["cwd"] = json!(cwd);
123    }
124
125    if let Some(raw_output) = response.raw_output {
126        ToolExecutionResult::success_with_raw_output(result, raw_output)
127    } else {
128        ToolExecutionResult::success(result)
129    }
130}
131
132fn build_sandbox_read_file_result(
133    response: crate::SessionSandboxReadFileResponse,
134    offset: usize,
135    limit: usize,
136) -> ToolExecutionResult {
137    if response.encoding != "text" && response.encoding != "utf-8" {
138        let bytes_returned = response.content.len();
139        let mut result = json!({
140            "path": response.path,
141            "content": response.content,
142            "encoding": response.encoding,
143            "size_bytes": bytes_returned,
144        });
145        TruncationInfo::not_truncated(bytes_returned).attach(&mut result);
146        return ToolExecutionResult::success(result);
147    }
148
149    ToolExecutionResult::success(build_text_read_file_result(
150        "sandbox_read_file",
151        &response.path,
152        &response.content,
153        &response.encoding,
154        offset,
155        limit,
156    ))
157}
158
159#[derive(Clone)]
160pub struct SandboxExecTool {
161    config: Value,
162}
163
164impl SandboxExecTool {
165    pub fn new(config: Value) -> Self {
166        Self { config }
167    }
168}
169
170#[async_trait]
171impl Tool for SandboxExecTool {
172    fn name(&self) -> &str {
173        "sandbox_exec"
174    }
175
176    fn description(&self) -> &str {
177        "Execute a shell command inside the session-managed sandbox."
178    }
179
180    fn parameters_schema(&self) -> Value {
181        json!({
182            "type": "object",
183            "properties": {
184                "command": { "type": "string", "description": "Shell command to execute" },
185                "cwd": { "type": "string", "description": "Optional working directory inside the sandbox" },
186                "timeout_ms": { "type": "integer", "minimum": 1, "description": "Optional execution timeout in milliseconds" },
187                "output": crate::tool_output_sanitizer::output_verbosity_schema()
188            },
189            "required": ["command"],
190            "additionalProperties": false
191        })
192    }
193
194    fn hints(&self) -> crate::ToolHints {
195        session_sandbox_tool_hints()
196    }
197
198    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
199        ToolExecutionResult::tool_error(
200            "sandbox_exec requires context. This tool must be executed with session context.",
201        )
202    }
203
204    async fn execute_with_context(
205        &self,
206        arguments: Value,
207        context: &ToolContext,
208    ) -> ToolExecutionResult {
209        let config = match parse_config(&self.config) {
210            Ok(config) => config,
211            Err(err) => return err,
212        };
213        let Some(command) = arguments.get("command").and_then(|v| v.as_str()) else {
214            return ToolExecutionResult::tool_error("Missing required parameter: command");
215        };
216        let timeout_ms = match arguments.get("timeout_ms") {
217            None => None,
218            Some(value) => match value.as_u64() {
219                Some(timeout_ms) if timeout_ms > 0 => Some(timeout_ms),
220                _ => {
221                    return ToolExecutionResult::tool_error(
222                        "timeout_ms must be a positive integer",
223                    );
224                }
225            },
226        };
227        let provider = match provider_for_config(&config) {
228            Ok(provider) => provider,
229            Err(err) => return err,
230        };
231        let state = match ensure_session_sandbox_running(context, &config).await {
232            Ok(state) => state,
233            Err(err) => return err,
234        };
235
236        match provider
237            .exec(
238                context,
239                &config,
240                &state.instance,
241                &crate::SessionSandboxExecRequest {
242                    command: command.to_string(),
243                    cwd: arguments
244                        .get("cwd")
245                        .and_then(|v| v.as_str())
246                        .map(ToString::to_string),
247                    timeout_ms,
248                    // EVE-489: persistence-first default — `auto` returns
249                    // compact summaries on success, diagnostics on failure.
250                    output_mode: arguments
251                        .get("output")
252                        .and_then(|v| v.as_str())
253                        .unwrap_or("auto")
254                        .to_string(),
255                },
256            )
257            .await
258        {
259            Ok(response) => {
260                build_sandbox_exec_result(response, arguments.get("cwd").and_then(|v| v.as_str()))
261            }
262            Err(err) => err,
263        }
264    }
265
266    fn requires_context(&self) -> bool {
267        true
268    }
269}
270
271#[derive(Clone)]
272pub struct SandboxReadFileTool {
273    config: Value,
274}
275
276impl SandboxReadFileTool {
277    pub fn new(config: Value) -> Self {
278        Self { config }
279    }
280}
281
282#[async_trait]
283impl Tool for SandboxReadFileTool {
284    fn name(&self) -> &str {
285        "sandbox_read_file"
286    }
287
288    fn description(&self) -> &str {
289        "Read a file from the session-managed sandbox filesystem."
290    }
291
292    fn parameters_schema(&self) -> Value {
293        json!({
294            "type": "object",
295            "properties": {
296                "path": { "type": "string", "description": "Path to read inside the sandbox" },
297                "offset": {
298                    "type": "integer",
299                    "minimum": 0,
300                    "default": 0,
301                    "description": "Zero-based line offset to start reading from"
302                },
303                "limit": {
304                    "type": "integer",
305                    "minimum": 1,
306                    "default": READ_FILE_DEFAULT_LIMIT,
307                    "description": "Maximum number of lines to return"
308                }
309            },
310            "required": ["path"],
311            "additionalProperties": false
312        })
313    }
314
315    fn hints(&self) -> crate::ToolHints {
316        session_sandbox_tool_hints().with_readonly(true)
317    }
318
319    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
320        ToolExecutionResult::tool_error(
321            "sandbox_read_file requires context. This tool must be executed with session context.",
322        )
323    }
324
325    async fn execute_with_context(
326        &self,
327        arguments: Value,
328        context: &ToolContext,
329    ) -> ToolExecutionResult {
330        let config = match parse_config(&self.config) {
331            Ok(config) => config,
332            Err(err) => return err,
333        };
334        let provider = match provider_for_config(&config) {
335            Ok(provider) => provider,
336            Err(err) => return err,
337        };
338        let state = match ensure_session_sandbox_running(context, &config).await {
339            Ok(state) => state,
340            Err(err) => return err,
341        };
342        let Some(path) = arguments.get("path").and_then(|v| v.as_str()) else {
343            return ToolExecutionResult::tool_error("Missing required parameter: path");
344        };
345        let (offset, limit) = match parse_read_file_window_args(&arguments) {
346            Ok(window) => window,
347            Err(err) => return ToolExecutionResult::tool_error(err),
348        };
349
350        match provider
351            .read_file(context, &config, &state.instance, path)
352            .await
353        {
354            Ok(response) => build_sandbox_read_file_result(response, offset, limit),
355            Err(err) => err,
356        }
357    }
358
359    fn requires_context(&self) -> bool {
360        true
361    }
362}
363
364#[derive(Clone)]
365pub struct SandboxWriteFileTool {
366    config: Value,
367}
368
369impl SandboxWriteFileTool {
370    pub fn new(config: Value) -> Self {
371        Self { config }
372    }
373}
374
375#[async_trait]
376impl Tool for SandboxWriteFileTool {
377    fn name(&self) -> &str {
378        "sandbox_write_file"
379    }
380
381    fn description(&self) -> &str {
382        "Write a file into the session-managed sandbox filesystem."
383    }
384
385    fn parameters_schema(&self) -> Value {
386        json!({
387            "type": "object",
388            "properties": {
389                "path": { "type": "string", "description": "Destination path inside the sandbox" },
390                "content": { "type": "string", "description": "File content to write" }
391            },
392            "required": ["path", "content"],
393            "additionalProperties": false
394        })
395    }
396
397    fn hints(&self) -> crate::ToolHints {
398        session_sandbox_tool_hints()
399    }
400
401    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
402        ToolExecutionResult::tool_error(
403            "sandbox_write_file requires context. This tool must be executed with session context.",
404        )
405    }
406
407    async fn execute_with_context(
408        &self,
409        arguments: Value,
410        context: &ToolContext,
411    ) -> ToolExecutionResult {
412        let config = match parse_config(&self.config) {
413            Ok(config) => config,
414            Err(err) => return err,
415        };
416        let provider = match provider_for_config(&config) {
417            Ok(provider) => provider,
418            Err(err) => return err,
419        };
420        let state = match ensure_session_sandbox_running(context, &config).await {
421            Ok(state) => state,
422            Err(err) => return err,
423        };
424        let Some(path) = arguments.get("path").and_then(|v| v.as_str()) else {
425            return ToolExecutionResult::tool_error("Missing required parameter: path");
426        };
427        let Some(content) = arguments.get("content").and_then(|v| v.as_str()) else {
428            return ToolExecutionResult::tool_error("Missing required parameter: content");
429        };
430
431        match provider
432            .write_file(context, &config, &state.instance, path, content)
433            .await
434        {
435            Ok(response) => ToolExecutionResult::success(json!({
436                "path": response.path,
437                "bytes_written": response.bytes_written,
438            })),
439            Err(err) => err,
440        }
441    }
442
443    fn requires_context(&self) -> bool {
444        true
445    }
446}
447
448#[derive(Clone)]
449pub struct SandboxStatusTool {
450    config: Value,
451}
452
453impl SandboxStatusTool {
454    pub fn new(config: Value) -> Self {
455        Self { config }
456    }
457}
458
459#[async_trait]
460impl Tool for SandboxStatusTool {
461    fn name(&self) -> &str {
462        "sandbox_status"
463    }
464
465    fn description(&self) -> &str {
466        "Inspect the current state of the session-managed sandbox."
467    }
468
469    fn parameters_schema(&self) -> Value {
470        json!({
471            "type": "object",
472            "properties": {},
473            "additionalProperties": false
474        })
475    }
476
477    fn hints(&self) -> crate::ToolHints {
478        session_sandbox_tool_hints()
479            .with_readonly(true)
480            .with_idempotent(true)
481    }
482
483    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
484        ToolExecutionResult::tool_error(
485            "sandbox_status requires context. This tool must be executed with session context.",
486        )
487    }
488
489    async fn execute_with_context(
490        &self,
491        _arguments: Value,
492        context: &ToolContext,
493    ) -> ToolExecutionResult {
494        let config = match parse_config(&self.config) {
495            Ok(config) => config,
496            Err(err) => return err,
497        };
498        let Some(state) = (match load_session_sandbox_state(context).await {
499            Ok(state) => state,
500            Err(err) => return err,
501        }) else {
502            return ToolExecutionResult::success(json!({
503                "exists": false,
504                "provider": config.provider,
505            }));
506        };
507        let provider = match provider_for_config(&config) {
508            Ok(provider) => provider,
509            Err(err) => return err,
510        };
511
512        match provider.status(context, &config, &state).await {
513            Ok(response) => ToolExecutionResult::success(json!({
514                "exists": true,
515                "provider": response.provider,
516                "session_status": response.session_status,
517                "external_id": response.external_id,
518                "display_name": response.display_name,
519                "workspace_path": response.workspace_path,
520                "metadata": response.metadata,
521            })),
522            Err(err) => err,
523        }
524    }
525
526    fn requires_context(&self) -> bool {
527        true
528    }
529}
530
531#[derive(Clone)]
532pub struct SandboxManageTool {
533    config: Value,
534}
535
536impl SandboxManageTool {
537    pub fn new(config: Value) -> Self {
538        Self { config }
539    }
540}
541
542#[async_trait]
543impl Tool for SandboxManageTool {
544    fn name(&self) -> &str {
545        "sandbox_manage"
546    }
547
548    fn description(&self) -> &str {
549        "Pause, resume, or delete the session-managed sandbox."
550    }
551
552    fn parameters_schema(&self) -> Value {
553        json!({
554            "type": "object",
555            "properties": {
556                "action": {
557                    "type": "string",
558                    "enum": ["pause", "resume", "delete"],
559                    "description": "Lifecycle action to apply"
560                }
561            },
562            "required": ["action"],
563            "additionalProperties": false
564        })
565    }
566
567    fn hints(&self) -> crate::ToolHints {
568        session_sandbox_tool_hints().with_destructive(true)
569    }
570
571    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
572        ToolExecutionResult::tool_error(
573            "sandbox_manage requires context. This tool must be executed with session context.",
574        )
575    }
576
577    async fn execute_with_context(
578        &self,
579        arguments: Value,
580        context: &ToolContext,
581    ) -> ToolExecutionResult {
582        let config = match parse_config(&self.config) {
583            Ok(config) => config,
584            Err(err) => return err,
585        };
586        let Some(action) = arguments.get("action").and_then(|v| v.as_str()) else {
587            return ToolExecutionResult::tool_error("Missing required parameter: action");
588        };
589
590        match action {
591            "pause" => match pause_session_sandbox(context, &config).await {
592                Ok(Some(state)) => ToolExecutionResult::success(json!({
593                    "action": action,
594                    "provider": state.provider,
595                    "session_status": state.status,
596                    "external_id": state.instance.external_id,
597                })),
598                Ok(None) => ToolExecutionResult::success(json!({
599                    "action": action,
600                    "exists": false,
601                })),
602                Err(err) => err,
603            },
604            "resume" => match ensure_session_sandbox_running(context, &config).await {
605                Ok(state) => ToolExecutionResult::success(json!({
606                    "action": action,
607                    "provider": state.provider,
608                    "session_status": state.status,
609                    "external_id": state.instance.external_id,
610                })),
611                Err(err) => err,
612            },
613            "delete" => match delete_session_sandbox(context, &config).await {
614                Ok(deleted) => ToolExecutionResult::success(json!({
615                    "action": action,
616                    "deleted": deleted,
617                })),
618                Err(err) => err,
619            },
620            _ => ToolExecutionResult::tool_error(
621                "Invalid action: must be one of pause, resume, delete",
622            ),
623        }
624    }
625
626    fn requires_context(&self) -> bool {
627        true
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634    use crate::capabilities::{Capability, CapabilityRegistry};
635    use crate::deployment::DeploymentGrade;
636    use crate::traits::ToolContext;
637
638    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
639
640    fn lock_env() -> std::sync::MutexGuard<'static, ()> {
641        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
642    }
643
644    #[test]
645    fn session_sandbox_capability_metadata() {
646        let cap = SessionSandboxCapability;
647        assert_eq!(cap.id(), SESSION_SANDBOX_CAPABILITY_ID);
648        assert_eq!(cap.name(), "Session Sandbox");
649        assert_eq!(cap.status(), CapabilityStatus::Available);
650        assert_eq!(cap.dependencies(), vec!["session_storage"]);
651    }
652
653    #[test]
654    fn session_sandbox_tools_with_config() {
655        let cap = SessionSandboxCapability;
656        let tools = cap.tools_with_config(&json!({"provider": "daytona"}));
657        let names: Vec<&str> = tools.iter().map(|tool| tool.name()).collect();
658        assert_eq!(names.len(), 5);
659        assert!(names.contains(&"sandbox_exec"));
660        assert!(names.contains(&"sandbox_read_file"));
661        assert!(names.contains(&"sandbox_write_file"));
662        assert!(names.contains(&"sandbox_status"));
663        assert!(names.contains(&"sandbox_manage"));
664    }
665
666    #[test]
667    fn session_sandbox_registry_is_flag_gated() {
668        let _lock = lock_env();
669        unsafe { std::env::remove_var("FEATURE_SESSION_SANDBOX") };
670        let registry = CapabilityRegistry::with_builtins_for_grade(DeploymentGrade::Dev);
671        assert!(!registry.has("session_sandbox"));
672
673        unsafe { std::env::set_var("FEATURE_SESSION_SANDBOX", "true") };
674        let registry = CapabilityRegistry::with_builtins_for_grade(DeploymentGrade::Dev);
675        assert!(registry.has("session_sandbox"));
676        unsafe { std::env::remove_var("FEATURE_SESSION_SANDBOX") };
677    }
678
679    #[tokio::test]
680    async fn sandbox_exec_rejects_zero_timeout() {
681        let tool = SandboxExecTool::new(json!({ "provider": "missing-provider" }));
682        let context = ToolContext::new(crate::typed_id::SessionId::new());
683
684        let result = tool
685            .execute_with_context(
686                json!({
687                    "command": "echo hi",
688                    "timeout_ms": 0,
689                }),
690                &context,
691            )
692            .await;
693
694        match result {
695            ToolExecutionResult::ToolError(message) => {
696                assert!(message.contains("timeout_ms must be a positive integer"));
697            }
698            other => panic!("expected ToolError, got {other:?}"),
699        }
700    }
701
702    #[test]
703    fn sandbox_exec_result_preserves_absent_raw_output() {
704        let result = build_sandbox_exec_result(
705            crate::SessionSandboxExecResponse {
706                exit_code: 0,
707                stdout: "ok".to_string(),
708                stderr: String::new(),
709                success: true,
710                truncated: false,
711                total_lines: 1,
712                raw_output: None,
713                hint: None,
714            },
715            Some("/workspace"),
716        )
717        .into_tool_result("call_1", "sandbox_exec");
718
719        assert_eq!(result.raw_output, None);
720        assert_eq!(result.result.unwrap()["cwd"], "/workspace");
721    }
722
723    #[test]
724    fn sandbox_exec_result_keeps_raw_output_sidecar_when_present() {
725        let result = build_sandbox_exec_result(
726            crate::SessionSandboxExecResponse {
727                exit_code: 17,
728                stdout: "trimmed".to_string(),
729                stderr: "warn".to_string(),
730                success: false,
731                truncated: true,
732                total_lines: 42,
733                raw_output: Some("full output".to_string()),
734                hint: Some("non-zero".to_string()),
735            },
736            None,
737        )
738        .into_tool_result("call_1", "sandbox_exec");
739
740        assert_eq!(result.raw_output.as_deref(), Some("full output"));
741        let payload = result.result.unwrap();
742        assert_eq!(payload["exit_code"], 17);
743        assert_eq!(payload["truncated"], true);
744        assert_eq!(payload["hint"], "non-zero");
745    }
746
747    #[test]
748    fn sandbox_read_file_result_applies_line_window() {
749        let result = build_sandbox_read_file_result(
750            crate::SessionSandboxReadFileResponse {
751                path: "/workspace/src/lib.rs".to_string(),
752                content: "alpha\nbeta\ngamma\ndelta".to_string(),
753                encoding: "text".to_string(),
754            },
755            1,
756            2,
757        )
758        .into_tool_result("call_1", "sandbox_read_file");
759
760        let payload = result.result.unwrap();
761        assert_eq!(payload["path"], "/workspace/src/lib.rs");
762        assert_eq!(payload["content"], "2|beta\n3|gamma");
763        assert_eq!(payload["total_lines"], 4);
764        assert_eq!(payload["lines_shown"]["start"], 2);
765        assert_eq!(payload["lines_shown"]["end"], 3);
766        assert_eq!(payload["truncated"], true);
767        assert_eq!(payload["truncation"]["next_offset"], 3);
768        assert!(
769            payload["truncation"]["resume_hint"]
770                .as_str()
771                .unwrap()
772                .contains("sandbox_read_file")
773        );
774    }
775
776    #[test]
777    fn sandbox_read_file_result_marks_untruncated_window() {
778        let result = build_sandbox_read_file_result(
779            crate::SessionSandboxReadFileResponse {
780                path: "/workspace/src/lib.rs".to_string(),
781                content: "alpha\nbeta".to_string(),
782                encoding: "text".to_string(),
783            },
784            0,
785            10,
786        )
787        .into_tool_result("call_1", "sandbox_read_file");
788
789        let payload = result.result.unwrap();
790        assert_eq!(payload["content"], "1|alpha\n2|beta");
791        assert_eq!(payload["truncated"], false);
792        assert_eq!(payload["truncation"]["truncated"], false);
793    }
794}