1use super::{Capability, CapabilityLocalization, CapabilityStatus};
7pub use crate::session_sandbox::SESSION_SANDBOX_CAPABILITY_ID;
8use crate::session_sandbox::{
9 DEFAULT_SESSION_SANDBOX_IDLE_TIMEOUT_SECS, SessionSandboxConfig, checkpoint_session_sandbox,
10 create_session_sandbox_provider, delete_session_sandbox, ensure_session_sandbox_running,
11 load_session_sandbox_state, pause_session_sandbox, 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("Sandboxes")
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 fn config_schema(&self) -> Option<Value> {
83 Some(json!({
84 "type": "object",
85 "properties": {
86 "provider": {
87 "type": "string",
88 "title": "Provider",
89 "description": "Sandbox provider id (e.g. daytona)."
90 },
91 "auto_start": {
92 "type": "boolean",
93 "title": "Auto-start",
94 "description": "Start the sandbox proactively when the session is created.",
95 "default": true
96 },
97 "idle_pause_after_seconds": {
98 "type": "integer",
99 "title": "Idle pause timeout (seconds)",
100 "description": "Pause the sandbox after this many seconds of session inactivity.",
101 "minimum": 1,
102 "default": DEFAULT_SESSION_SANDBOX_IDLE_TIMEOUT_SECS
103 }
104 }
105 }))
106 }
107
108 fn validate_config(&self, config: &Value) -> Result<(), String> {
109 if config.is_null() {
113 return Ok(());
114 }
115 let Some(object) = config.as_object() else {
116 return Err("session_sandbox config must be an object".to_string());
117 };
118 if object.is_empty() {
119 return Ok(());
120 }
121 let typed: SessionSandboxConfig = serde_json::from_value(config.clone())
122 .map_err(|e| format!("invalid session_sandbox config: {e}"))?;
123 if typed.provider.trim().is_empty() {
125 return Err("session_sandbox requires a non-empty provider".to_string());
126 }
127 if typed.idle_pause_after_seconds == 0 {
128 return Err("idle_pause_after_seconds must be >= 1".to_string());
129 }
130 Ok(())
131 }
132
133 fn localizations(&self) -> Vec<CapabilityLocalization> {
134 vec![
135 CapabilityLocalization {
136 locale: "en",
137 name: None,
138 description: None,
139 config_description: Some(
140 "Controls the sandbox provider, auto-start behavior, and how long the \
141 sandbox may sit idle before pausing.",
142 ),
143 config_overlay: None,
144 },
145 CapabilityLocalization {
146 locale: "uk",
147 name: Some("Пісочниця сесії"),
148 description: Some(
149 "Одна керована пісочниця, що належить поточній сесії. Підтримує \
150 виконання команд і файлові операції з життєвим циклом, яким керує \
151 провайдер.",
152 ),
153 config_description: Some(
154 "Визначає провайдера пісочниці, автозапуск і час простою до призупинення.",
155 ),
156 config_overlay: Some(json!({
157 "properties": {
158 "provider": {
159 "title": "Провайдер",
160 "description": "Ідентифікатор провайдера пісочниці (наприклад, daytona)."
161 },
162 "auto_start": {
163 "title": "Автозапуск",
164 "description": "Запускати пісочницю одразу після створення сесії."
165 },
166 "idle_pause_after_seconds": {
167 "title": "Призупинення після простою (секунди)",
168 "description": "Призупиняти пісочницю після зазначеної кількості секунд неактивності сесії."
169 }
170 }
171 })),
172 },
173 ]
174 }
175}
176
177fn parse_config(config: &Value) -> Result<SessionSandboxConfig, ToolExecutionResult> {
178 let config: SessionSandboxConfig = serde_json::from_value(config.clone()).map_err(|e| {
179 ToolExecutionResult::tool_error(format!("Invalid session_sandbox capability config: {e}"))
180 })?;
181
182 if config.provider.trim().is_empty() {
183 return Err(ToolExecutionResult::tool_error(
184 "session_sandbox capability requires a non-empty provider",
185 ));
186 }
187 if config.idle_pause_after_seconds == 0 {
188 return Err(ToolExecutionResult::tool_error(
189 "session_sandbox idle_pause_after_seconds must be >= 1",
190 ));
191 }
192
193 Ok(config)
194}
195
196fn provider_for_config(
197 config: &SessionSandboxConfig,
198) -> Result<Box<dyn crate::SessionSandboxProvider>, ToolExecutionResult> {
199 create_session_sandbox_provider(&config.provider).ok_or_else(|| {
200 ToolExecutionResult::tool_error(format!(
201 "Session sandbox provider '{}' is not registered",
202 config.provider
203 ))
204 })
205}
206
207fn build_sandbox_exec_result(
208 response: crate::SessionSandboxExecResponse,
209 cwd: Option<&str>,
210) -> ToolExecutionResult {
211 let mut result = json!({
212 "stdout": response.stdout,
213 "stderr": response.stderr,
214 "exit_code": response.exit_code,
215 "success": response.success,
216 "truncated": response.truncated,
217 "total_lines": response.total_lines,
218 "hint": response.hint,
219 });
220 if let Some(cwd) = cwd {
221 result["cwd"] = json!(cwd);
222 }
223
224 if let Some(raw_output) = response.raw_output {
225 ToolExecutionResult::success_with_raw_output(result, raw_output)
226 } else {
227 ToolExecutionResult::success(result)
228 }
229}
230
231fn build_sandbox_read_file_result(
232 response: crate::SessionSandboxReadFileResponse,
233 offset: usize,
234 limit: usize,
235) -> ToolExecutionResult {
236 if response.encoding != "text" && response.encoding != "utf-8" {
237 let bytes_returned = response.content.len();
238 let mut result = json!({
239 "path": response.path,
240 "content": response.content,
241 "encoding": response.encoding,
242 "size_bytes": bytes_returned,
243 });
244 TruncationInfo::not_truncated(bytes_returned).attach(&mut result);
245 return ToolExecutionResult::success(result);
246 }
247
248 ToolExecutionResult::success(build_text_read_file_result(
249 "sandbox_read_file",
250 &response.path,
251 &response.content,
252 &response.encoding,
253 offset,
254 limit,
255 ))
256}
257
258#[derive(Clone)]
259pub struct SandboxExecTool {
260 config: Value,
261}
262
263impl SandboxExecTool {
264 pub fn new(config: Value) -> Self {
265 Self { config }
266 }
267}
268
269#[async_trait]
270impl Tool for SandboxExecTool {
271 fn narrate(
272 &self,
273 tool_call: &crate::tool_types::ToolCall,
274 phase: crate::tool_narration::ToolNarrationPhase,
275 locale: Option<&str>,
276 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
277 ) -> Option<String> {
278 let fallback = self.display_name().unwrap_or("Sandbox");
279 Some(crate::tool_narration::narrate_shell_exec(
280 &tool_call.arguments,
281 fallback,
282 phase,
283 locale,
284 ))
285 }
286
287 fn name(&self) -> &str {
288 "sandbox_exec"
289 }
290
291 fn description(&self) -> &str {
292 "Execute a shell command inside the session-managed sandbox."
293 }
294
295 fn parameters_schema(&self) -> Value {
296 json!({
297 "type": "object",
298 "properties": {
299 "command": { "type": "string", "description": "Shell command to execute" },
300 "cwd": { "type": "string", "description": "Optional working directory inside the sandbox" },
301 "timeout_ms": { "type": "integer", "minimum": 1, "description": "Optional execution timeout in milliseconds" },
302 "output": crate::tool_output_sanitizer::output_verbosity_schema()
303 },
304 "required": ["command"],
305 "additionalProperties": false
306 })
307 }
308
309 fn hints(&self) -> crate::ToolHints {
310 session_sandbox_tool_hints()
311 }
312
313 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
314 ToolExecutionResult::tool_error(
315 "sandbox_exec requires context. This tool must be executed with session context.",
316 )
317 }
318
319 async fn execute_with_context(
320 &self,
321 arguments: Value,
322 context: &ToolContext,
323 ) -> ToolExecutionResult {
324 let config = match parse_config(&self.config) {
325 Ok(config) => config,
326 Err(err) => return err,
327 };
328 let Some(command) = arguments.get("command").and_then(|v| v.as_str()) else {
329 return ToolExecutionResult::tool_error("Missing required parameter: command");
330 };
331 let timeout_ms = match arguments.get("timeout_ms") {
332 None => None,
333 Some(value) => match value.as_u64() {
334 Some(timeout_ms) if timeout_ms > 0 => Some(timeout_ms),
335 _ => {
336 return ToolExecutionResult::tool_error(
337 "timeout_ms must be a positive integer",
338 );
339 }
340 },
341 };
342 let provider = match provider_for_config(&config) {
343 Ok(provider) => provider,
344 Err(err) => return err,
345 };
346 let mut state = match ensure_session_sandbox_running(context, &config).await {
347 Ok(state) => state,
348 Err(err) => return err,
349 };
350
351 match provider
352 .exec(
353 context,
354 &config,
355 &state.instance,
356 &crate::SessionSandboxExecRequest {
357 command: command.to_string(),
358 cwd: arguments
359 .get("cwd")
360 .and_then(|v| v.as_str())
361 .map(ToString::to_string),
362 timeout_ms,
363 output_mode: arguments
366 .get("output")
367 .and_then(|v| v.as_str())
368 .unwrap_or("auto")
369 .to_string(),
370 },
371 )
372 .await
373 {
374 Ok(response) => {
375 if let Err(err) =
376 checkpoint_session_sandbox(context, provider.as_ref(), &config, &mut state)
377 .await
378 {
379 return err;
380 }
381 build_sandbox_exec_result(response, arguments.get("cwd").and_then(|v| v.as_str()))
382 }
383 Err(err) => err,
384 }
385 }
386
387 fn requires_context(&self) -> bool {
388 true
389 }
390}
391
392#[derive(Clone)]
393pub struct SandboxReadFileTool {
394 config: Value,
395}
396
397impl SandboxReadFileTool {
398 pub fn new(config: Value) -> Self {
399 Self { config }
400 }
401}
402
403#[async_trait]
404impl Tool for SandboxReadFileTool {
405 fn narrate(
406 &self,
407 tool_call: &crate::tool_types::ToolCall,
408 phase: crate::tool_narration::ToolNarrationPhase,
409 locale: Option<&str>,
410 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
411 ) -> Option<String> {
412 Some(crate::tool_narration::narrate_read_file(
413 &tool_call.arguments,
414 phase,
415 locale,
416 ))
417 }
418
419 fn name(&self) -> &str {
420 "sandbox_read_file"
421 }
422
423 fn description(&self) -> &str {
424 "Read a file from the session-managed sandbox filesystem."
425 }
426
427 fn parameters_schema(&self) -> Value {
428 json!({
429 "type": "object",
430 "properties": {
431 "path": { "type": "string", "description": "Path to read inside the sandbox" },
432 "offset": {
433 "type": "integer",
434 "minimum": 0,
435 "default": 0,
436 "description": "Zero-based line offset to start reading from"
437 },
438 "limit": {
439 "type": "integer",
440 "minimum": 1,
441 "default": READ_FILE_DEFAULT_LIMIT,
442 "description": "Maximum number of lines to return"
443 }
444 },
445 "required": ["path"],
446 "additionalProperties": false
447 })
448 }
449
450 fn hints(&self) -> crate::ToolHints {
451 session_sandbox_tool_hints().with_readonly(true)
452 }
453
454 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
455 ToolExecutionResult::tool_error(
456 "sandbox_read_file requires context. This tool must be executed with session context.",
457 )
458 }
459
460 async fn execute_with_context(
461 &self,
462 arguments: Value,
463 context: &ToolContext,
464 ) -> ToolExecutionResult {
465 let config = match parse_config(&self.config) {
466 Ok(config) => config,
467 Err(err) => return err,
468 };
469 let provider = match provider_for_config(&config) {
470 Ok(provider) => provider,
471 Err(err) => return err,
472 };
473 let state = match ensure_session_sandbox_running(context, &config).await {
474 Ok(state) => state,
475 Err(err) => return err,
476 };
477 let Some(path) = arguments.get("path").and_then(|v| v.as_str()) else {
478 return ToolExecutionResult::tool_error("Missing required parameter: path");
479 };
480 let (offset, limit) = match parse_read_file_window_args(&arguments) {
481 Ok(window) => window,
482 Err(err) => return ToolExecutionResult::tool_error(err),
483 };
484
485 match provider
486 .read_file(context, &config, &state.instance, path)
487 .await
488 {
489 Ok(response) => build_sandbox_read_file_result(response, offset, limit),
490 Err(err) => err,
491 }
492 }
493
494 fn requires_context(&self) -> bool {
495 true
496 }
497}
498
499#[derive(Clone)]
500pub struct SandboxWriteFileTool {
501 config: Value,
502}
503
504impl SandboxWriteFileTool {
505 pub fn new(config: Value) -> Self {
506 Self { config }
507 }
508}
509
510#[async_trait]
511impl Tool for SandboxWriteFileTool {
512 fn narrate(
513 &self,
514 tool_call: &crate::tool_types::ToolCall,
515 phase: crate::tool_narration::ToolNarrationPhase,
516 locale: Option<&str>,
517 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
518 ) -> Option<String> {
519 Some(crate::tool_narration::narrate_write_file(
520 &tool_call.arguments,
521 phase,
522 locale,
523 ))
524 }
525
526 fn name(&self) -> &str {
527 "sandbox_write_file"
528 }
529
530 fn description(&self) -> &str {
531 "Write a file into the session-managed sandbox filesystem."
532 }
533
534 fn parameters_schema(&self) -> Value {
535 json!({
536 "type": "object",
537 "properties": {
538 "path": { "type": "string", "description": "Destination path inside the sandbox" },
539 "content": { "type": "string", "description": "File content to write" }
540 },
541 "required": ["path", "content"],
542 "additionalProperties": false
543 })
544 }
545
546 fn hints(&self) -> crate::ToolHints {
547 session_sandbox_tool_hints()
548 }
549
550 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
551 ToolExecutionResult::tool_error(
552 "sandbox_write_file requires context. This tool must be executed with session context.",
553 )
554 }
555
556 async fn execute_with_context(
557 &self,
558 arguments: Value,
559 context: &ToolContext,
560 ) -> ToolExecutionResult {
561 let config = match parse_config(&self.config) {
562 Ok(config) => config,
563 Err(err) => return err,
564 };
565 let provider = match provider_for_config(&config) {
566 Ok(provider) => provider,
567 Err(err) => return err,
568 };
569 let mut state = match ensure_session_sandbox_running(context, &config).await {
570 Ok(state) => state,
571 Err(err) => return err,
572 };
573 let Some(path) = arguments.get("path").and_then(|v| v.as_str()) else {
574 return ToolExecutionResult::tool_error("Missing required parameter: path");
575 };
576 let Some(content) = arguments.get("content").and_then(|v| v.as_str()) else {
577 return ToolExecutionResult::tool_error("Missing required parameter: content");
578 };
579
580 match provider
581 .write_file(context, &config, &state.instance, path, content)
582 .await
583 {
584 Ok(response) => {
585 if let Err(err) =
586 checkpoint_session_sandbox(context, provider.as_ref(), &config, &mut state)
587 .await
588 {
589 return err;
590 }
591 ToolExecutionResult::success(json!({
592 "path": response.path,
593 "bytes_written": response.bytes_written,
594 }))
595 }
596 Err(err) => err,
597 }
598 }
599
600 fn requires_context(&self) -> bool {
601 true
602 }
603}
604
605#[derive(Clone)]
606pub struct SandboxStatusTool {
607 config: Value,
608}
609
610impl SandboxStatusTool {
611 pub fn new(config: Value) -> Self {
612 Self { config }
613 }
614}
615
616#[async_trait]
617impl Tool for SandboxStatusTool {
618 fn narrate(
619 &self,
620 _tool_call: &crate::tool_types::ToolCall,
621 phase: crate::tool_narration::ToolNarrationPhase,
622 locale: Option<&str>,
623 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
624 ) -> Option<String> {
625 Some(crate::tool_narration::narrate_sandbox_status(phase, locale))
626 }
627
628 fn name(&self) -> &str {
629 "sandbox_status"
630 }
631
632 fn description(&self) -> &str {
633 "Inspect the current state of the session-managed sandbox."
634 }
635
636 fn parameters_schema(&self) -> Value {
637 json!({
638 "type": "object",
639 "properties": {},
640 "additionalProperties": false
641 })
642 }
643
644 fn hints(&self) -> crate::ToolHints {
645 session_sandbox_tool_hints()
646 .with_readonly(true)
647 .with_idempotent(true)
648 }
649
650 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
651 ToolExecutionResult::tool_error(
652 "sandbox_status requires context. This tool must be executed with session context.",
653 )
654 }
655
656 async fn execute_with_context(
657 &self,
658 _arguments: Value,
659 context: &ToolContext,
660 ) -> ToolExecutionResult {
661 let config = match parse_config(&self.config) {
662 Ok(config) => config,
663 Err(err) => return err,
664 };
665 let Some(state) = (match load_session_sandbox_state(context).await {
666 Ok(state) => state,
667 Err(err) => return err,
668 }) else {
669 return ToolExecutionResult::success(json!({
670 "exists": false,
671 "provider": config.provider,
672 }));
673 };
674 let provider = match provider_for_config(&config) {
675 Ok(provider) => provider,
676 Err(err) => return err,
677 };
678
679 match provider.status(context, &config, &state).await {
680 Ok(response) => ToolExecutionResult::success(json!({
681 "exists": true,
682 "provider": response.provider,
683 "session_status": response.session_status,
684 "external_id": response.external_id,
685 "display_name": response.display_name,
686 "workspace_path": response.workspace_path,
687 "metadata": response.metadata,
688 })),
689 Err(err) => err,
690 }
691 }
692
693 fn requires_context(&self) -> bool {
694 true
695 }
696}
697
698#[derive(Clone)]
699pub struct SandboxManageTool {
700 config: Value,
701}
702
703impl SandboxManageTool {
704 pub fn new(config: Value) -> Self {
705 Self { config }
706 }
707}
708
709#[async_trait]
710impl Tool for SandboxManageTool {
711 fn narrate(
712 &self,
713 tool_call: &crate::tool_types::ToolCall,
714 phase: crate::tool_narration::ToolNarrationPhase,
715 locale: Option<&str>,
716 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
717 ) -> Option<String> {
718 Some(crate::tool_narration::narrate_sandbox_manage(
719 &tool_call.arguments,
720 phase,
721 locale,
722 ))
723 }
724
725 fn name(&self) -> &str {
726 "sandbox_manage"
727 }
728
729 fn description(&self) -> &str {
730 "Pause, resume, or delete the session-managed sandbox."
731 }
732
733 fn parameters_schema(&self) -> Value {
734 json!({
735 "type": "object",
736 "properties": {
737 "action": {
738 "type": "string",
739 "enum": ["pause", "resume", "delete"],
740 "description": "Lifecycle action to apply"
741 }
742 },
743 "required": ["action"],
744 "additionalProperties": false
745 })
746 }
747
748 fn hints(&self) -> crate::ToolHints {
749 session_sandbox_tool_hints().with_destructive(true)
750 }
751
752 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
753 ToolExecutionResult::tool_error(
754 "sandbox_manage requires context. This tool must be executed with session context.",
755 )
756 }
757
758 async fn execute_with_context(
759 &self,
760 arguments: Value,
761 context: &ToolContext,
762 ) -> ToolExecutionResult {
763 let config = match parse_config(&self.config) {
764 Ok(config) => config,
765 Err(err) => return err,
766 };
767 let Some(action) = arguments.get("action").and_then(|v| v.as_str()) else {
768 return ToolExecutionResult::tool_error("Missing required parameter: action");
769 };
770
771 match action {
772 "pause" => match pause_session_sandbox(context, &config).await {
773 Ok(Some(state)) => ToolExecutionResult::success(json!({
774 "action": action,
775 "provider": state.provider,
776 "session_status": state.status,
777 "external_id": state.instance.external_id,
778 })),
779 Ok(None) => ToolExecutionResult::success(json!({
780 "action": action,
781 "exists": false,
782 })),
783 Err(err) => err,
784 },
785 "resume" => match ensure_session_sandbox_running(context, &config).await {
786 Ok(state) => ToolExecutionResult::success(json!({
787 "action": action,
788 "provider": state.provider,
789 "session_status": state.status,
790 "external_id": state.instance.external_id,
791 })),
792 Err(err) => err,
793 },
794 "delete" => match delete_session_sandbox(context, &config).await {
795 Ok(deleted) => ToolExecutionResult::success(json!({
796 "action": action,
797 "deleted": deleted,
798 })),
799 Err(err) => err,
800 },
801 _ => ToolExecutionResult::tool_error(
802 "Invalid action: must be one of pause, resume, delete",
803 ),
804 }
805 }
806
807 fn requires_context(&self) -> bool {
808 true
809 }
810}
811
812#[cfg(test)]
813mod tests {
814 use super::*;
815 use crate::capabilities::{Capability, CapabilityRegistry};
816 use crate::deployment::DeploymentGrade;
817 use crate::traits::ToolContext;
818
819 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
820
821 fn lock_env() -> std::sync::MutexGuard<'static, ()> {
822 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
823 }
824
825 #[test]
828 fn session_sandbox_tools_with_config() {
829 let cap = SessionSandboxCapability;
830 let tools = cap.tools_with_config(&json!({"provider": "daytona"}));
831 let names: Vec<&str> = tools.iter().map(|tool| tool.name()).collect();
832 assert_eq!(names.len(), 5);
833 assert!(names.contains(&"sandbox_exec"));
834 assert!(names.contains(&"sandbox_read_file"));
835 assert!(names.contains(&"sandbox_write_file"));
836 assert!(names.contains(&"sandbox_status"));
837 assert!(names.contains(&"sandbox_manage"));
838 }
839
840 #[test]
841 fn session_sandbox_tools_share_concurrency_class() {
842 let cap = SessionSandboxCapability;
843 let tools = cap.tools_with_config(&json!({"provider": "daytona"}));
844
845 for tool in tools {
846 let definition = tool.to_definition();
847 assert_eq!(
848 definition.concurrency_class(),
849 Some("session_sandbox"),
850 "{} should serialize against other session sandbox tools",
851 tool.name()
852 );
853 }
854 }
855
856 #[test]
857 fn session_sandbox_config_schema_and_validation() {
858 let cap = SessionSandboxCapability;
859
860 let schema = cap.config_schema().expect("config schema");
861 assert_eq!(schema["type"], "object");
862 assert!(schema["properties"]["provider"].is_object());
863 assert!(schema["properties"]["auto_start"].is_object());
864 assert!(schema["properties"]["idle_pause_after_seconds"].is_object());
865 assert!(schema["properties"].get("provider_config").is_none());
867 assert!(schema["properties"].get("init").is_none());
868
869 assert!(cap.validate_config(&serde_json::Value::Null).is_ok());
871 assert!(cap.validate_config(&json!({})).is_ok());
872
873 assert!(
875 cap.validate_config(&json!({
876 "provider": "daytona",
877 "auto_start": false,
878 "idle_pause_after_seconds": 60,
879 "provider_config": { "snapshot": "base" },
880 "init": { "commands": ["echo ok"] }
881 }))
882 .is_ok()
883 );
884
885 assert!(cap.validate_config(&json!({ "provider": " " })).is_err());
887 let err = cap
888 .validate_config(&json!({
889 "provider": "daytona",
890 "idle_pause_after_seconds": 0
891 }))
892 .unwrap_err();
893 assert!(err.contains("idle_pause_after_seconds"));
894 }
895
896 #[test]
897 fn session_sandbox_localizations_resolve_uk() {
898 let cap = SessionSandboxCapability;
899 assert_eq!(cap.localized_name(Some("uk-UA")), "Пісочниця сесії");
900 assert!(cap.describe_schema(None).is_some());
901 }
902
903 #[test]
904 fn session_sandbox_registry_is_flag_gated() {
905 let _lock = lock_env();
906 unsafe { std::env::remove_var("FEATURE_SESSION_SANDBOX") };
907 let registry = CapabilityRegistry::with_builtins_for_grade(DeploymentGrade::Dev);
908 assert!(!registry.has("session_sandbox"));
909
910 unsafe { std::env::set_var("FEATURE_SESSION_SANDBOX", "true") };
911 let registry = CapabilityRegistry::with_builtins_for_grade(DeploymentGrade::Dev);
912 assert!(registry.has("session_sandbox"));
913 unsafe { std::env::remove_var("FEATURE_SESSION_SANDBOX") };
914 }
915
916 #[tokio::test]
917 async fn sandbox_exec_rejects_zero_timeout() {
918 let tool = SandboxExecTool::new(json!({ "provider": "missing-provider" }));
919 let context = ToolContext::new(crate::typed_id::SessionId::new());
920
921 let result = tool
922 .execute_with_context(
923 json!({
924 "command": "echo hi",
925 "timeout_ms": 0,
926 }),
927 &context,
928 )
929 .await;
930
931 match result {
932 ToolExecutionResult::ToolError(message) => {
933 assert!(message.contains("timeout_ms must be a positive integer"));
934 }
935 other => panic!("expected ToolError, got {other:?}"),
936 }
937 }
938
939 #[test]
940 fn sandbox_exec_result_preserves_absent_raw_output() {
941 let result = build_sandbox_exec_result(
942 crate::SessionSandboxExecResponse {
943 exit_code: 0,
944 stdout: "ok".to_string(),
945 stderr: String::new(),
946 success: true,
947 truncated: false,
948 total_lines: 1,
949 raw_output: None,
950 hint: None,
951 },
952 Some("/workspace"),
953 )
954 .into_tool_result("call_1", "sandbox_exec");
955
956 assert_eq!(result.raw_output, None);
957 assert_eq!(result.result.unwrap()["cwd"], "/workspace");
958 }
959
960 #[test]
961 fn sandbox_exec_result_keeps_raw_output_sidecar_when_present() {
962 let result = build_sandbox_exec_result(
963 crate::SessionSandboxExecResponse {
964 exit_code: 17,
965 stdout: "trimmed".to_string(),
966 stderr: "warn".to_string(),
967 success: false,
968 truncated: true,
969 total_lines: 42,
970 raw_output: Some("full output".to_string()),
971 hint: Some("non-zero".to_string()),
972 },
973 None,
974 )
975 .into_tool_result("call_1", "sandbox_exec");
976
977 assert_eq!(result.raw_output.as_deref(), Some("full output"));
978 let payload = result.result.unwrap();
979 assert_eq!(payload["exit_code"], 17);
980 assert_eq!(payload["truncated"], true);
981 assert_eq!(payload["hint"], "non-zero");
982 }
983
984 #[test]
985 fn sandbox_read_file_result_applies_line_window() {
986 let result = build_sandbox_read_file_result(
987 crate::SessionSandboxReadFileResponse {
988 path: "/workspace/src/lib.rs".to_string(),
989 content: "alpha\nbeta\ngamma\ndelta".to_string(),
990 encoding: "text".to_string(),
991 },
992 1,
993 2,
994 )
995 .into_tool_result("call_1", "sandbox_read_file");
996
997 let payload = result.result.unwrap();
998 assert_eq!(payload["path"], "/workspace/src/lib.rs");
999 assert_eq!(payload["content"], "2|beta\n3|gamma");
1000 assert_eq!(payload["total_lines"], 4);
1001 assert_eq!(payload["lines_shown"]["start"], 2);
1002 assert_eq!(payload["lines_shown"]["end"], 3);
1003 assert_eq!(payload["truncated"], true);
1004 assert_eq!(payload["truncation"]["next_offset"], 3);
1005 assert!(
1006 payload["truncation"]["resume_hint"]
1007 .as_str()
1008 .unwrap()
1009 .contains("sandbox_read_file")
1010 );
1011 }
1012
1013 #[test]
1014 fn sandbox_read_file_result_marks_untruncated_window() {
1015 let result = build_sandbox_read_file_result(
1016 crate::SessionSandboxReadFileResponse {
1017 path: "/workspace/src/lib.rs".to_string(),
1018 content: "alpha\nbeta".to_string(),
1019 encoding: "text".to_string(),
1020 },
1021 0,
1022 10,
1023 )
1024 .into_tool_result("call_1", "sandbox_read_file");
1025
1026 let payload = result.result.unwrap();
1027 assert_eq!(payload["content"], "1|alpha\n2|beta");
1028 assert_eq!(payload["truncated"], false);
1029 assert_eq!(payload["truncation"]["truncated"], false);
1030 }
1031}