1use 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 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}